ui: better granularity support, comparison view for bar chart

This commit is contained in:
nick-delirium 2024-11-28 18:08:42 +01:00
parent 92412685b0
commit 08be943ae0
No known key found for this signature in database
GPG key ID: 93ABD695DF5FDBA0
16 changed files with 277 additions and 83 deletions

View file

@ -33,8 +33,6 @@ function CustomMetricLineChart(props: Props) {
hideLegend = false,
} = props;
console.log(data.namesMap, data.chart)
return (
<ResponsiveContainer height={240} width="100%">
<AreaChart
@ -50,7 +48,7 @@ function CustomMetricLineChart(props: Props) {
vertical={false}
stroke="#EEEEEE"
/>
<XAxis {...Styles.xaxis} dataKey="time" interval={params.density / 7} />
<XAxis {...Styles.xaxis} dataKey="time" interval={'equidistantPreserveStart'} />
<YAxis
{...yaxis}
allowDecimals={false}

View file

@ -10,11 +10,11 @@ import {
BarChart,
Bar,
Legend,
Rectangle,
} from 'recharts';
interface Props {
data: any;
data: { chart: any[], namesMap: string[] };
compData: { chart: any[], namesMap: string[] } | null;
params: any;
colors: any;
onClick?: (event, index) => void;
@ -22,6 +22,7 @@ interface Props {
label?: string;
hideLegend?: boolean;
}
const getPath = (x, y, width, height) => {
const radius = Math.min(width / 2, height / 2);
return `
@ -39,15 +40,35 @@ const getPath = (x, y, width, height) => {
};
const PillBar = (props) => {
const { fill, x, y, width, height } = props;
const { fill, x, y, width, height, striped } = props;
return <path d={getPath(x, y, width, height)} stroke="none" fill={fill} />;
return (
<g transform={`translate(${x}, ${y})`}>
<rect
width={width}
height={height}
rx={Math.min(width / 2, height / 2)}
ry={Math.min(width / 2, height / 2)}
fill={fill}
/>
{striped && (
<rect
width={width}
height={height}
clipPath="url(#pillClip)"
fill="url(#diagonalStripes)"
/>
)}
</g>
);
};
function CustomMetricLineChart(props: Props) {
const {
data = { chart: [], namesMap: [] },
compData,
params,
colors,
onClick = () => null,
@ -56,13 +77,32 @@ function CustomMetricLineChart(props: Props) {
hideLegend = false,
} = props;
const resultChart = data.chart.map((item, i) => {
if (compData && compData.chart[i]) return { ...compData.chart[i], ...item };
return item;
});
return (
<ResponsiveContainer height={240} width="100%">
<BarChart
data={data.chart}
data={resultChart}
margin={Styles.chartMargins}
onClick={onClick}
>
<defs>
<clipPath id="pillClip">
<rect x="0" y="0" width="100%" height="100%" rx="10" ry="10" />
</clipPath>
<pattern
id="diagonalStripes"
patternUnits="userSpaceOnUse"
width="8"
height="8"
patternTransform="rotate(45)"
>
<line x1="0" y="0" x2="0" y2="8" stroke="white" strokeWidth="6" />
</pattern>
</defs>
{!hideLegend && (
<Legend iconType={'circle'} wrapperStyle={{ top: -26 }} />
)}
@ -71,7 +111,11 @@ function CustomMetricLineChart(props: Props) {
vertical={false}
stroke="#EEEEEE"
/>
<XAxis {...Styles.xaxis} dataKey="time" interval={params.density / 7} />
<XAxis
{...Styles.xaxis}
dataKey="time"
interval={'equidistantPreserveStart'}
/>
<YAxis
{...yaxis}
allowDecimals={false}
@ -89,13 +133,32 @@ function CustomMetricLineChart(props: Props) {
name={key}
type="monotone"
dataKey={key}
shape={<PillBar />}
fill={colors[index]}
shape={(barProps) => (
<PillBar {...barProps} fill={colors[index]} />
)}
legendType={key === 'Total' ? 'none' : 'line'}
activeBar={<PillBar fill={colors[index]} stroke={colors[index]} />}
// strokeDasharray={'4 3'} FOR COPMARISON ONLY
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}
</BarChart>
</ResponsiveContainer>
);

View file

@ -2,6 +2,7 @@ import React from 'react';
import { formatTimeOrDate } from 'App/date';
import cn from 'classnames';
import { ArrowUp, ArrowDown } from 'lucide-react'
function CustomTooltip({ active, payload, label }) {
if (!active) return;
@ -33,7 +34,7 @@ function CustomTooltip({ active, payload, label }) {
className={'flex flex-col gap-1 bg-white shadow border rounded p-2 z-30'}
>
{transformedArray.map((p, index) => (
<>
<React.Fragment key={p.name + index}>
<div className={'flex gap-2 items-center'}>
<div
style={{ borderRadius: 99, background: p.color }}
@ -67,7 +68,7 @@ function CustomTooltip({ active, payload, label }) {
) : null}
</div>
</div>
</>
</React.Fragment>
))}
</div>
);

View file

@ -45,6 +45,7 @@ function CustomMetricLineChart(props: Props) {
if (compData && compData.chart[i]) return { ...compData.chart[i], ...item }
return item
})
return (
<ResponsiveContainer height={240} width="100%">
<LineChart
@ -60,7 +61,7 @@ function CustomMetricLineChart(props: Props) {
vertical={false}
stroke="#EEEEEE"
/>
<XAxis {...Styles.xaxis} dataKey="time" interval={params.density / 7} />
<XAxis {...Styles.xaxis} dataKey="time" interval={'equidistantPreserveStart'} />
<YAxis
{...yaxis}
allowDecimals={false}
@ -72,25 +73,25 @@ function CustomMetricLineChart(props: Props) {
/>
<Tooltip {...Styles.tooltip} content={CustomTooltip} />
{Array.isArray(data.namesMap) &&
data.namesMap.map((key, index) => key ? (
<Line
key={key}
name={key}
animationDuration={0}
type="monotone"
dataKey={key}
stroke={colors[index]}
fillOpacity={1}
strokeWidth={2}
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)}
data.namesMap.map((key, index) => key ? (
<Line
key={key}
name={key}
animationDuration={0}
type="monotone"
dataKey={key}
stroke={colors[index]}
fillOpacity={1}
strokeWidth={2}
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) => (
<Line
key={key}

View file

@ -56,8 +56,8 @@ function WidgetChart(props: Props) {
const drillDownFilter = dashboardStore.drillDownFilter;
const colors = Styles.customMetricColors;
const [loading, setLoading] = useState(true);
const params = { density: 70 };
const metricParams = { ...params };
const params = { density: dashboardStore.selectedDensity }
const metricParams = _metric.params;
const prevMetricRef = useRef<any>();
const isMounted = useIsMounted();
const [data, setData] = useState<any>(metric.data);
@ -153,8 +153,8 @@ function WidgetChart(props: Props) {
prevMetricRef.current = metric;
const timestmaps = drillDownPeriod.toTimestamps();
const payload = isSaved
? { ...params }
: { ...metricParams, ...timestmaps, ...metric.toJson() };
? { ...metricParams }
: { ...params, ...timestmaps, ...metric.toJson() };
debounceRequest(
metric,
payload,
@ -167,7 +167,7 @@ function WidgetChart(props: Props) {
if (!dashboardStore.comparisonPeriod) return setCompData(null);
const timestamps = dashboardStore.comparisonPeriod.toTimestamps();
const payload = { ...metricParams, ...timestamps, ...metric.toJson() };
const payload = { ...params, ...timestamps, ...metric.toJson() };
fetchMetricChartData(metric, payload, isSaved, dashboardStore.comparisonPeriod, true);
}
useEffect(() => {
@ -180,6 +180,7 @@ function WidgetChart(props: Props) {
drillDownPeriod,
period,
depsString,
dashboardStore.selectedDensity,
metric.metricType,
metric.metricOf,
metric.metricValue,
@ -257,6 +258,7 @@ function WidgetChart(props: Props) {
return (
<BarChart
data={chartData}
compData={compData}
params={params}
colors={colors}
onClick={onChartClick}

View file

@ -0,0 +1,64 @@
import React from 'react'
import AntlikeDropdown from "Shared/Dropdown";
import { DownOutlined } from "@ant-design/icons";
function RangeGranularity({
period,
density,
onDensityChange
}: {
period: any,
density: number,
onDensityChange: (density: number) => void
}) {
const granularityOptions = React.useMemo(() => {
return calculateGranularities(period.getDuration());
}, [period]);
const menuProps = {
items: granularityOptions,
onClick: (item: any) => onDensityChange(item.key),
}
const selected = React.useMemo(() => {
let selected = 'Custom';
for (const option of granularityOptions) {
if (option.key <= density) {
selected = option.label;
break;
}
}
return selected;
}, [])
return (
<AntlikeDropdown
useButtonStyle
label={selected}
rightIcon={<DownOutlined />}
menuProps={menuProps}
/>
)
}
function calculateGranularities(periodDurationMs: number) {
const granularities = [
{ label: '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 },
{ label: 'Monthly', durationMs: 30 * 24 * 60 * 60 * 1000 },
{ label: 'Quarterly', durationMs: 3 * 30 * 24 * 60 * 60 * 1000 },
];
const result = [];
for (const granularity of granularities) {
if (periodDurationMs >= granularity.durationMs) {
const density = Math.floor(periodDurationMs / granularity.durationMs);
result.push({ label: granularity.label, key: density });
}
}
return result;
}
export default RangeGranularity;

View file

@ -3,9 +3,14 @@ import SelectDateRange from 'Shared/SelectDateRange';
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
import { Space } from 'antd';
import RangeGranularity from "./RangeGranularity";
function WidgetDateRange({ label = 'Time Range', isTimeseries = false }: any) {
function WidgetDateRange({ viewType = undefined, label = 'Time Range', isTimeseries = false }: any) {
const { dashboardStore } = useStore();
const density = dashboardStore.selectedDensity
const onDensityChange = (density: number) => {
dashboardStore.setDensity(density);
}
const period = dashboardStore.drillDownPeriod;
const compPeriod = dashboardStore.comparisonPeriod;
const drillDownFilter = dashboardStore.drillDownFilter;
@ -29,6 +34,8 @@ function WidgetDateRange({ label = 'Time Range', isTimeseries = false }: any) {
});
}
const hasGranularity = ['lineChart', 'barChart', 'areaChart'].includes(viewType);
const hasCompare = ['lineChart', 'barChart', 'table', 'progressChart'].includes(viewType);
return (
<Space>
{label && <span className="mr-1 color-gray-medium">{label}</span>}
@ -40,16 +47,27 @@ function WidgetDateRange({ label = 'Time Range', isTimeseries = false }: any) {
useButtonStyle={true}
/>
{isTimeseries ? (
<SelectDateRange
period={period}
compPeriod={compPeriod}
onChange={onChangePeriod}
onChangeComparison={onChangeComparison}
right={true}
isAnt={true}
useButtonStyle={true}
comparison={true}
/>
<>
{hasGranularity ? (
<RangeGranularity
period={period}
density={density}
onDensityChange={onDensityChange}
/>
) : null}
{hasCompare ?
<SelectDateRange
period={period}
compPeriod={compPeriod}
onChange={onChangePeriod}
onChangeComparison={onChangeComparison}
right={true}
isAnt={true}
useButtonStyle={true}
comparison={true}
/>
: null}
</>
) : null}
</Space>
);

View file

@ -82,7 +82,6 @@ const FilterSection = observer(({ metric, excludeFilterKeys }: any) => {
metric.updateKey('hasChanged', true)
}
console.log(metric.series, isTable, isClickMap, isInsights, isPathAnalysis, isFunnel)
return (
<>
{metric.series.length > 0 &&

View file

@ -9,14 +9,14 @@ import { observer } from 'mobx-react-lite';
import { ChartLine, ChartArea, ChartColumn, ChartBar, ChartPie, Table } from 'lucide-react'
function WidgetOptions() {
const { metricStore, dashboardStore } = useStore();
const { metricStore } = useStore();
const metric: any = metricStore.instance;
const handleChange = (value: any) => {
metric.update({ metricFormat: value });
};
const chartTypes = {
const chartTypes = {
lineChart: 'Chart',
barChart: 'Column',
areaChart: 'Area',

View file

@ -26,7 +26,7 @@ function WidgetPreview(props: Props) {
className={cn(className, 'bg-white rounded-xl border shadow-sm mt-0')}
>
<div className="flex items-center gap-2 px-4 pt-2">
<WidgetDateRange label="" isTimeseries={metric.metricType === TIMESERIES} />
<WidgetDateRange label="" isTimeseries={metric.metricType === TIMESERIES} viewType={metric.viewType} />
<div className="flex items-center ml-auto">
<WidgetOptions />
{/*{metric.metricType === USER_PATH && (*/}

View file

@ -0,0 +1,36 @@
import React from 'react';
import { Dropdown } from 'antd';
function AntlikeDropdown(props: {
label: string;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
menuProps: any;
useButtonStyle?: boolean;
className?: string;
}) {
const { label, leftIcon, rightIcon, menuProps, useButtonStyle, className } = props;
return (
<Dropdown menu={menuProps} className={'px-2 py-1'}>
{useButtonStyle ? (
<div
className={
'flex items-center gap-2 border border-gray-light rounded cursor-pointer'
}
>
{leftIcon}
<span>{label}</span>
{rightIcon}
</div>
) : (
<div className={'cursor-pointer flex items-center gap-2'}>
{leftIcon}
<span>{label}</span>
{rightIcon}
</div>
)}
</Dropdown>
);
}
export default AntlikeDropdown;

View file

@ -146,13 +146,28 @@ export function AutoCompleteContainer(props: Props) {
: props.value[0]}
</div>
{props.value.length > 1 ? (
<div
className={
'rounded-xl bg-gray-lighter leading-none px-1 py-0.5'
}
>
+ {props.value.length - 1} More
</div>
props.value.length === 2 ? (
<>
or
<div
className={
'rounded-xl bg-gray-lighter leading-none px-1 py-0.5'
}
>
{props.mapValues
? props.mapValues(props.value[1])
: props.value[1]}
</div>
</>
) : (
<div
className={
'rounded-xl bg-gray-lighter leading-none px-1 py-0.5'
}
>
+ {props.value.length - 1} More
</div>
)
) : null}
</>
) : (

View file

@ -120,7 +120,6 @@ const FilterAutoComplete = observer(
setOptions(topValues.map((i) => ({ value: i.value, label: i.value })));
};
console.log(options)
return <AutocompleteModal
values={values}
onClose={onClose}

View file

@ -14,6 +14,7 @@ import { Calendar } from 'lucide-react';
import DateRangePopup from 'Shared/DateRangeDropdown/DateRangePopup';
import OutsideClickDetectingDiv from 'Shared/OutsideClickDetectingDiv';
import Select from 'Shared/Select';
import AntlikeDropdown from "Shared/Dropdown";
interface Props {
period: any | null;
@ -24,8 +25,8 @@ interface Props {
isAnt?: boolean;
small?: boolean;
useButtonStyle?: boolean; // New prop to control button style
compPeriod: any | null;
onChangeComparison: (data: any) => void;
compPeriod?: any | null;
onChangeComparison?: (data: any) => void;
comparison?: boolean;
[x: string]: any;
}
@ -217,24 +218,13 @@ function AndDateRange({
</div>
</div>
) : (
<Dropdown menu={menuProps} className={'px-2 py-1'}>
{useButtonStyle ? (
<div
className={
'flex items-center gap-2 border border-gray-light rounded cursor-pointer'
}
>
<Calendar size={16} />
<span>{isCustomRange ? customRange : selectedValue?.label}</span>
<DownOutlined />
</div>
) : (
<div className={'cursor-pointer flex items-center gap-2'}>
<span>{isCustomRange ? customRange : selectedValue?.label}</span>
<DownOutlined />
</div>
)}
</Dropdown>
<AntlikeDropdown
label={isCustomRange ? customRange : selectedValue?.label}
menuProps={menuProps}
useButtonStyle={useButtonStyle}
leftIcon={useButtonStyle ? <Calendar size={16} /> : null}
rightIcon={<DownOutlined />}
/>
)}
{isCustom && (
<OutsideClickDetectingDiv

View file

@ -24,6 +24,7 @@ export default class DashboardStore {
drillDownFilter: Filter = new Filter();
comparisonFilter: Filter = new Filter();
drillDownPeriod: Record<string, any> = Period({ rangeName: LAST_7_DAYS });
selectedDensity: number = 7 // depends on default drilldown, 7 points here!!!;
comparisonPeriod: Record<string, any> | null = null
startTimestamp: number = 0;
endTimestamp: number = 0;
@ -57,6 +58,10 @@ export default class DashboardStore {
this.resetDrillDownFilter();
}
setDensity = (density: any) => {
this.selectedDensity = parseInt(density, 10);
}
get sortedDashboards() {
const sortOrder = this.sort.by;
return [...this.dashboards].sort((a, b) =>

View file

@ -130,6 +130,9 @@ export default Record(
endTimestamp: this.end,
};
},
getDuration() {
return this.range.end.diff(this.range.start).as("milliseconds");
},
rangeFormatted(format = "MMM dd yyyy, HH:mm", tz) {
const start = this.range.start.setZone(tz);
const end = this.range.end.setZone(tz);