refactor(dashboard): the chart component is now cleaned up and easier to extend

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-09-12 09:30:48 +02:00
parent 3e19f90e51
commit 558761ca9d
76 changed files with 2910 additions and 2475 deletions

View File

@@ -0,0 +1,279 @@
'use client';
import React, { useCallback } from 'react';
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { api } from '@/trpc/client';
import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import { isSameDay, isSameHour, isSameMonth } from 'date-fns';
import { last } from 'ramda';
import {
Area,
CartesianGrid,
ComposedChart,
Legend,
Line,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { useXAxisProps, useYAxisProps } from '../common/axis';
import { ReportChartTooltip } from '../common/report-chart-tooltip';
import { ReportTable } from '../common/report-table';
import { SerieIcon } from '../common/serie-icon';
import { SerieName } from '../common/serie-name';
import { useReportChartContext } from '../context';
interface Props {
data: IChartData;
}
export function Chart({ data }: Props) {
const {
report: {
previous,
interval,
projectId,
startDate,
endDate,
range,
lineType,
},
isEditMode,
options: { hideXAxis, hideYAxis },
} = useReportChartContext();
const dataLength = data.series[0]?.data?.length || 0;
const references = api.reference.getChartReferences.useQuery(
{
projectId,
startDate,
endDate,
range,
},
{
staleTime: 1000 * 60 * 10,
}
);
const { series, setVisibleSeries } = useVisibleSeries(data);
const rechartData = useRechartDataModel(series);
// great care should be taken when computing lastIntervalPercent
// the expression below works for data.length - 1 equal intervals
// but if there are numeric x values in a "linear" axis, the formula
// should be updated to use those values
const lastIntervalPercent =
((rechartData.length - 2) * 100) / (rechartData.length - 1);
const gradientTwoColors = (
id: string,
col1: string,
col2: string,
percentChange: number
) => (
<linearGradient id={id} x1="0" y1="0" x2="100%" y2="0">
<stop offset="0%" stopColor={col1} />
<stop offset={`${percentChange}%`} stopColor={col1} />
<stop offset={`${percentChange}%`} stopColor={`${col2}`} />
<stop offset="100%" stopColor={col2} />
</linearGradient>
);
const lastSerieDataItem = last(series[0]?.data || [])?.date || new Date();
const useDashedLastLine = (() => {
if (interval === 'hour') {
return isSameHour(lastSerieDataItem, new Date());
}
if (interval === 'day') {
return isSameDay(lastSerieDataItem, new Date());
}
if (interval === 'month') {
return isSameMonth(lastSerieDataItem, new Date());
}
return false;
})();
const CustomLegend = useCallback(() => {
return (
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1">
{series.map((serie) => (
<div
className="flex items-center gap-1"
key={serie.id}
style={{
color: getChartColor(serie.index),
}}
>
<SerieIcon name={serie.names} />
<SerieName name={serie.names} />
</div>
))}
</div>
);
}, [series]);
const yAxisProps = useYAxisProps({
data: [data.metrics.max],
hide: hideYAxis,
});
const xAxisProps = useXAxisProps({
hide: hideXAxis,
interval,
});
const isAreaStyle = series.length === 1;
return (
<>
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
<ResponsiveContainer>
<ComposedChart data={rechartData}>
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}
vertical={false}
className="stroke-border"
/>
{references.data?.map((ref) => (
<ReferenceLine
key={ref.id}
x={ref.date.getTime()}
stroke={'#94a3b8'}
strokeDasharray={'3 3'}
label={{
value: ref.title,
position: 'centerTop',
fill: '#334155',
fontSize: 12,
}}
fontSize={10}
/>
))}
<YAxis {...yAxisProps} />
<XAxis {...xAxisProps} />
{series.length > 1 && (
<Legend
wrapperStyle={{ fontSize: '10px' }}
content={<CustomLegend />}
/>
)}
<Tooltip content={<ReportChartTooltip />} />
{series.map((serie) => {
const color = getChartColor(serie.index);
return (
<React.Fragment key={serie.id}>
<defs>
{isAreaStyle && (
<linearGradient
id={`color${color}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={color}
stopOpacity={0.8}
></stop>
<stop
offset="100%"
stopColor={color}
stopOpacity={0.1}
></stop>
</linearGradient>
)}
{gradientTwoColors(
`hideAllButLastInterval_${serie.id}`,
'rgba(0,0,0,0)',
color,
lastIntervalPercent
)}
{gradientTwoColors(
`hideJustLastInterval_${serie.id}`,
color,
'rgba(0,0,0,0)',
lastIntervalPercent
)}
</defs>
<Line
dot={isAreaStyle && dataLength <= 8}
type={lineType}
name={serie.id}
isAnimationActive={false}
strokeWidth={2}
dataKey={`${serie.id}:count`}
stroke={useDashedLastLine ? 'transparent' : color}
// Use for legend
fill={color}
/>
{isAreaStyle && (
<Area
dot={false}
name={`${serie.id}:area:noTooltip`}
dataKey={`${serie.id}:count`}
fill={`url(#color${color})`}
type={lineType}
isAnimationActive={false}
fillOpacity={0.1}
/>
)}
{useDashedLastLine && (
<>
<Line
dot={false}
type={lineType}
name={`${serie.id}:dashed:noTooltip`}
isAnimationActive={false}
strokeWidth={2}
dataKey={`${serie.id}:count`}
stroke={`url('#hideAllButLastInterval_${serie.id}')`}
strokeDasharray="4 2"
strokeOpacity={0.7}
/>
<Line
dot={false}
type={lineType}
name={`${serie.id}:solid:noTooltip`}
isAnimationActive={false}
strokeWidth={2}
dataKey={`${serie.id}:count`}
stroke={`url('#hideJustLastInterval_${serie.id}')`}
/>
</>
)}
{previous && (
<Line
type={lineType}
name={`${serie.id}:prev`}
isAnimationActive
dot={false}
strokeOpacity={0.3}
dataKey={`${serie.id}:prev:count`}
stroke={color}
// Use for legend
fill={color}
/>
)}
</React.Fragment>
);
})}
</ComposedChart>
</ResponsiveContainer>
</div>
{isEditMode && (
<ReportTable
data={data}
visibleSeries={series}
setVisibleSeries={setVisibleSeries}
/>
)}
</>
);
}

View File

@@ -0,0 +1,59 @@
import { api } from '@/trpc/client';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
import { ReportChartLoading } from '../common/loading';
import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportAreaChart() {
const { isLazyLoading, report } = useReportChartContext();
const res = api.chart.chart.useQuery(report, {
keepPreviousData: true,
staleTime: 1000 * 60 * 1,
});
if (isLazyLoading || res.isLoading || res.isFetching) {
return <Loading />;
}
if (res.isError) {
return <Error />;
}
if (res.data.series.length === 0) {
return <Empty />;
}
return (
<AspectContainer>
<Chart data={res.data} />
</AspectContainer>
);
}
function Loading() {
return (
<AspectContainer>
<ReportChartLoading />
</AspectContainer>
);
}
function Error() {
return (
<AspectContainer>
<ReportChartError />
</AspectContainer>
);
}
function Empty() {
return (
<AspectContainer>
<ReportChartEmpty />
</AspectContainer>
);
}

View File

@@ -0,0 +1,32 @@
'use client';
import { cn } from '@/utils/cn';
import { DEFAULT_ASPECT_RATIO } from '@openpanel/constants';
import { useReportChartContext } from './context';
interface AspectContainerProps {
children: React.ReactNode;
className?: string;
}
export function AspectContainer({ children, className }: AspectContainerProps) {
const { options } = useReportChartContext();
const minHeight = options?.minHeight ?? 100;
const maxHeight = options?.maxHeight ?? 300;
const aspectRatio = options?.aspectRatio ?? DEFAULT_ASPECT_RATIO;
return (
<div
className={cn('w-full', className)}
style={{
aspectRatio: 1 / aspectRatio,
maxHeight,
minHeight,
}}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,109 @@
'use client';
import { useMemo } from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useNumber } from '@/hooks/useNumerFormatter';
import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { DropdownMenuPortal } from '@radix-ui/react-dropdown-menu';
import { round } from '@openpanel/common';
import { NOT_SET_VALUE } from '@openpanel/constants';
import { PreviousDiffIndicator } from '../common/previous-diff-indicator';
import { SerieIcon } from '../common/serie-icon';
import { SerieName } from '../common/serie-name';
import { useReportChartContext } from '../context';
interface Props {
data: IChartData;
}
export function Chart({ data }: Props) {
const {
isEditMode,
report: { metric, limit },
options: { onClick, dropdownMenuContent },
} = useReportChartContext();
const number = useNumber();
const series = useMemo(
() => (isEditMode ? data.series : data.series.slice(0, limit || 10)),
[data, isEditMode, limit]
);
const maxCount = Math.max(...series.map((serie) => serie.metrics[metric]));
return (
<div
className={cn(
'flex flex-col text-sm',
isEditMode ? 'card gap-2 p-4 text-base' : '-m-3 gap-1'
)}
>
{series.map((serie) => {
const isClickable = !serie.names.includes(NOT_SET_VALUE) && onClick;
const isDropDownEnabled =
!serie.names.includes(NOT_SET_VALUE) &&
(dropdownMenuContent?.(serie) || []).length > 0;
return (
<DropdownMenu key={serie.id}>
<DropdownMenuTrigger asChild disabled={!isDropDownEnabled}>
<div
className={cn(
'relative',
(isClickable || isDropDownEnabled) && 'cursor-pointer'
)}
{...(isClickable && !isDropDownEnabled
? { onClick: () => onClick?.(serie) }
: {})}
>
<div
className="absolute bottom-0.5 left-1 right-1 top-0.5 rounded bg-def-200"
style={{
width: `calc(${(serie.metrics.sum / maxCount) * 100}% - 8px)`,
}}
/>
<div className="relative z-10 flex w-full flex-1 items-center gap-4 overflow-hidden px-3 py-2">
<div className="flex flex-1 items-center gap-2 break-all font-medium">
<SerieIcon name={serie.names[0]} />
<SerieName name={serie.names} />
</div>
<div className="flex flex-shrink-0 items-center justify-end gap-4 font-mono">
<PreviousDiffIndicator
{...serie.metrics.previous?.[metric]}
/>
{serie.metrics.previous?.[metric]?.value}
<div className="text-muted-foreground">
{number.format(
round((serie.metrics.sum / data.metrics.sum) * 100, 2)
)}
%
</div>
<div className="font-bold">
{number.format(serie.metrics.sum)}
</div>
</div>
</div>
</div>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
{dropdownMenuContent?.(serie).map((item) => (
<DropdownMenuItem key={item.title} onClick={item.onClick}>
{item.icon && <item.icon size={16} className="mr-2" />}
{item.title}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu>
);
})}
</div>
);
}

View File

@@ -0,0 +1,63 @@
import { api } from '@/trpc/client';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportBarChart() {
const { isLazyLoading, report } = useReportChartContext();
const res = api.chart.chart.useQuery(report, {
keepPreviousData: true,
staleTime: 1000 * 60 * 1,
});
if (isLazyLoading || res.isLoading || res.isFetching) {
return <Loading />;
}
if (res.isError) {
return <Error />;
}
if (res.data.series.length === 0) {
return <Empty />;
}
return <Chart data={res.data} />;
}
function Loading() {
return (
<AspectContainer className="col gap-4 overflow-hidden">
{Array.from({ length: 10 }).map((_, index) => (
<div key={index} className="row animate-pulse justify-between">
<div className="h-4 w-2/5 rounded bg-def-200"></div>
<div className="row w-1/5 gap-2">
<div className="h-4 w-full rounded bg-def-200"></div>
<div className="h-4 w-full rounded bg-def-200"></div>
<div className="h-4 w-full rounded bg-def-200"></div>
</div>
</div>
))}
</AspectContainer>
);
}
function Error() {
return (
<AspectContainer>
<ReportChartError />
</AspectContainer>
);
}
function Empty() {
return (
<AspectContainer>
<ReportChartEmpty />
</AspectContainer>
);
}

View File

@@ -0,0 +1,81 @@
import { useRef, useState } from 'react';
import { useDebounceFn } from '@/hooks/useDebounceFn';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useNumber } from '@/hooks/useNumerFormatter';
import { isNil } from 'ramda';
import type { AxisDomain } from 'recharts/types/util/types';
import type { IInterval } from '@openpanel/validation';
export const AXIS_FONT_PROPS = {
fontSize: 8,
className: 'font-mono',
};
export function getYAxisWidth(value: string | undefined | null) {
const charLength = AXIS_FONT_PROPS.fontSize * 0.6;
if (isNil(value) || value.length === 0) {
return charLength * 2;
}
return charLength * value.length + charLength;
}
export const useYAxisProps = ({
data,
hide,
}: {
data: number[];
hide?: boolean;
}) => {
const [width, setWidth] = useState(24);
const setWidthDebounced = useDebounceFn(setWidth, 100);
const number = useNumber();
const ref = useRef<number[]>([]);
return {
...AXIS_FONT_PROPS,
width: hide ? 0 : width,
axisLine: false,
tickLine: false,
allowDecimals: false,
tickFormatter: (value: number) => {
const tick = number.short(value);
const newWidth = getYAxisWidth(tick);
ref.current.push(newWidth);
setWidthDebounced(Math.max(...ref.current));
return tick;
},
};
};
export const useXAxisProps = (
{
interval = 'auto',
hide,
}: {
interval?: IInterval | 'auto';
hide?: boolean;
} = {
hide: false,
interval: 'auto',
}
) => {
const formatDate = useFormatDateInterval(
interval === 'auto' ? 'day' : interval
);
return {
height: hide ? 0 : undefined,
axisLine: false,
dataKey: 'timestamp',
scale: 'utc',
domain: ['dataMin', 'dataMax'] as AxisDomain,
tickFormatter:
interval === 'auto' ? undefined : (m: string) => formatDate(new Date(m)),
type: 'number' as const,
tickLine: false,
minTickGap: 20,
...AXIS_FONT_PROPS,
} as const;
};

View File

@@ -0,0 +1,13 @@
import { BirdIcon } from 'lucide-react';
export function ReportChartEmpty() {
return (
<div className="center-center h-full w-full flex-col">
<BirdIcon
strokeWidth={1.2}
className="mb-4 size-10 animate-pulse text-muted-foreground"
/>
<div className="text-sm font-medium text-muted-foreground">No data</div>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { ServerCrashIcon } from 'lucide-react';
export function ReportChartError() {
return (
<div className="center-center h-full w-full flex-col">
<ServerCrashIcon
strokeWidth={1.2}
className="mb-4 size-10 animate-pulse text-muted-foreground"
/>
<div className="text-sm font-medium text-muted-foreground">
There was an error loading this chart.
</div>
</div>
);
}

View File

@@ -0,0 +1,3 @@
export function ReportChartLoading() {
return <div className="h-full w-full animate-pulse rounded bg-def-100"></div>;
}

View File

@@ -0,0 +1,92 @@
import { useNumber } from '@/hooks/useNumerFormatter';
import { cn } from '@/utils/cn';
import { ArrowDownIcon, ArrowUpIcon } from 'lucide-react';
import { useReportChartContext } from '../context';
export function getDiffIndicator<A, B, C>(
inverted: boolean | undefined,
state: string | undefined | null,
positive: A,
negative: B,
neutral: C
): A | B | C {
if (state === 'neutral' || !state) {
return neutral;
}
if (inverted === true) {
return state === 'positive' ? negative : positive;
}
return state === 'positive' ? positive : negative;
}
// TODO: Fix this mess!
interface PreviousDiffIndicatorProps {
diff?: number | null | undefined;
state?: string | null | undefined;
children?: React.ReactNode;
inverted?: boolean;
className?: string;
size?: 'sm' | 'lg';
}
export function PreviousDiffIndicator({
diff,
state,
inverted,
size = 'sm',
children,
className,
}: PreviousDiffIndicatorProps) {
const {
report: { previousIndicatorInverted, previous },
} = useReportChartContext();
const variant = getDiffIndicator(
inverted ?? previousIndicatorInverted,
state,
'bg-emerald-300',
'bg-rose-300',
undefined
);
const number = useNumber();
if (diff === null || diff === undefined || previous === false) {
return children ?? null;
}
const renderIcon = () => {
if (state === 'positive') {
return <ArrowUpIcon strokeWidth={3} size={10} color="#000" />;
}
if (state === 'negative') {
return <ArrowDownIcon strokeWidth={3} size={10} color="#000" />;
}
return null;
};
return (
<>
<div
className={cn(
'flex items-center gap-1 font-mono font-medium',
size === 'lg' && 'gap-2',
className
)}
>
<div
className={cn(
`flex size-4 items-center justify-center rounded-full`,
variant,
size === 'lg' && 'size-8'
)}
>
{renderIcon()}
</div>
{number.format(diff)}%
</div>
{children}
</>
);
}

View File

@@ -0,0 +1,149 @@
import React, { useEffect, useState } from 'react';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useNumber } from '@/hooks/useNumerFormatter';
import type { IRechartPayloadItem } from '@/hooks/useRechartDataModel';
import type { IToolTipProps } from '@/types';
import * as Portal from '@radix-ui/react-portal';
import { bind } from 'bind-event-listener';
import throttle from 'lodash.throttle';
import { useReportChartContext } from '../context';
import { PreviousDiffIndicator } from './previous-diff-indicator';
import { SerieIcon } from './serie-icon';
import { SerieName } from './serie-name';
type ReportLineChartTooltipProps = IToolTipProps<{
value: number;
name: string;
dataKey: string;
payload: Record<string, unknown>;
}>;
export function ReportChartTooltip({
active,
payload,
}: ReportLineChartTooltipProps) {
const {
report: { interval, unit },
} = useReportChartContext();
const formatDate = useFormatDateInterval(interval);
const number = useNumber();
const [position, setPosition] = useState<{ x: number; y: number } | null>(
null
);
const inactive = !active || !payload?.length;
useEffect(() => {
const setPositionThrottled = throttle(setPosition, 50);
const unsubMouseMove = bind(window, {
type: 'mousemove',
listener(event) {
if (!inactive) {
setPositionThrottled({ x: event.clientX, y: event.clientY + 20 });
}
},
});
const unsubDragEnter = bind(window, {
type: 'pointerdown',
listener() {
setPosition(null);
},
});
return () => {
unsubMouseMove();
unsubDragEnter();
};
}, [inactive]);
if (inactive) {
return null;
}
const limit = 3;
const sorted = payload
.slice(0)
.filter((item) => !item.dataKey.includes(':prev:count'))
.filter((item) => !item.name.includes(':noTooltip'))
.sort((a, b) => b.value - a.value);
const visible = sorted.slice(0, limit);
const hidden = sorted.slice(limit);
const correctXPosition = (x: number | undefined) => {
if (!x) {
return undefined;
}
const tooltipWidth = 300;
const screenWidth = window.innerWidth;
const newX = x;
if (newX + tooltipWidth > screenWidth) {
return screenWidth - tooltipWidth;
}
return newX;
};
return (
<Portal.Portal
style={{
position: 'fixed',
top: position?.y,
left: correctXPosition(position?.x),
zIndex: 1000,
}}
>
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
{visible.map((item, index) => {
// If we have a <Cell /> component, payload can be nested
const payload = item.payload.payload ?? item.payload;
const data = (
item.dataKey.includes(':')
? // @ts-expect-error
payload[`${item.dataKey.split(':')[0]}:payload`]
: payload
) as IRechartPayloadItem;
return (
<React.Fragment key={data.id}>
{index === 0 && data.date && (
<div className="flex justify-between gap-8">
<div>{formatDate(new Date(data.date))}</div>
</div>
)}
<div className="flex gap-2">
<div
className="w-[3px] rounded-full"
style={{ background: data.color }}
/>
<div className="col flex-1 gap-1">
<div className="flex items-center gap-1">
<SerieIcon name={data.names} />
<SerieName name={data.names} />
</div>
<div className="flex justify-between gap-8 font-mono font-medium">
<div className="row gap-1">
{number.formatWithUnit(data.count, unit)}
{!!data.previous && (
<span className="text-muted-foreground">
({number.formatWithUnit(data.previous.value, unit)})
</span>
)}
</div>
<PreviousDiffIndicator {...data.previous} />
</div>
</div>
</div>
</React.Fragment>
);
})}
{hidden.length > 0 && (
<div className="text-muted-foreground">
and {hidden.length} more...
</div>
)}
</div>
</Portal.Portal>
);
}

View File

@@ -0,0 +1,190 @@
import * as React from 'react';
import { Pagination, usePagination } from '@/components/pagination';
import { Stats, StatsCard } from '@/components/stats';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Tooltiper } from '@/components/ui/tooltip';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useNumber } from '@/hooks/useNumerFormatter';
import { useSelector } from '@/redux';
import { getPropertyLabel } from '@/translations/properties';
import type { IChartData } from '@/trpc/client';
import { getChartColor } from '@/utils/theme';
import { PreviousDiffIndicator } from './previous-diff-indicator';
import { SerieName } from './serie-name';
interface ReportTableProps {
data: IChartData;
visibleSeries: IChartData['series'];
setVisibleSeries: React.Dispatch<React.SetStateAction<string[]>>;
}
const ROWS_LIMIT = 50;
export function ReportTable({
data,
visibleSeries,
setVisibleSeries,
}: ReportTableProps) {
const { setPage, paginate, page } = usePagination(ROWS_LIMIT);
const number = useNumber();
const interval = useSelector((state) => state.report.interval);
const breakdowns = useSelector((state) => state.report.breakdowns);
const formatDate = useFormatDateInterval(interval);
function handleChange(name: string, checked: boolean) {
setVisibleSeries((prev) => {
if (checked) {
return [...prev, name];
} else {
return prev.filter((item) => item !== name);
}
});
}
return (
<>
<Stats className="my-4">
<StatsCard title="Total" value={number.format(data.metrics.sum)} />
<StatsCard
title="Average"
value={number.format(data.metrics.average)}
/>
<StatsCard title="Min" value={number.format(data.metrics.min)} />
<StatsCard title="Max" value={number.format(data.metrics.max)} />
</Stats>
<div className="grid grid-cols-[max(300px,30vw)_1fr] overflow-hidden rounded-md border border-border">
<Table className="rounded-none border-b-0 border-l-0 border-t-0">
<TableHeader>
<TableRow>
{breakdowns.length === 0 && <TableHead>Name</TableHead>}
{breakdowns.map((breakdown) => (
<TableHead key={breakdown.name}>
{getPropertyLabel(breakdown.name)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody className="bg-def-100">
{paginate(data.series).map((serie, index) => {
const checked = !!visibleSeries.find(
(item) => item.id === serie.id
);
return (
<TableRow key={serie.id}>
{serie.names.map((name, nameIndex) => {
return (
<TableCell className="h-10" key={name}>
<div className="flex items-center gap-2">
{nameIndex === 0 ? (
<>
<Checkbox
onCheckedChange={(checked) =>
handleChange(serie.id, !!checked)
}
style={
checked
? {
background: getChartColor(index),
borderColor: getChartColor(index),
}
: undefined
}
checked={checked}
/>
<Tooltiper
side="left"
sideOffset={30}
content={<SerieName name={serie.names} />}
>
{name}
</Tooltiper>
</>
) : (
<SerieName name={name} />
)}
</div>
</TableCell>
);
})}
</TableRow>
);
})}
</TableBody>
</Table>
<div className="overflow-auto">
<Table className="rounded-none border-none">
<TableHeader>
<TableRow>
<TableHead>Total</TableHead>
<TableHead>Average</TableHead>
{data.series[0]?.data.map((serie) => (
<TableHead
key={serie.date.toString()}
className="whitespace-nowrap"
>
{formatDate(serie.date)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{paginate(data.series).map((serie) => {
return (
<TableRow key={serie.id}>
<TableCell className="h-10">
<div className="flex items-center gap-2 font-medium">
{number.format(serie.metrics.sum)}
<PreviousDiffIndicator
{...serie.metrics.previous?.sum}
/>
</div>
</TableCell>
<TableCell className="h-10">
<div className="flex items-center gap-2 font-medium">
{number.format(serie.metrics.average)}
<PreviousDiffIndicator
{...serie.metrics.previous?.average}
/>
</div>
</TableCell>
{serie.data.map((item) => {
return (
<TableCell className="h-10" key={item.date.toString()}>
<div className="flex items-center gap-2">
{number.format(item.count)}
<PreviousDiffIndicator {...item.previous} />
</div>
</TableCell>
);
})}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
<div className="row mt-4 justify-end">
<Pagination
cursor={page}
setCursor={setPage}
take={ROWS_LIMIT}
count={data.series.length}
/>
</div>
</>
);
}

View File

@@ -0,0 +1,190 @@
import type { LucideIcon, LucideProps } from 'lucide-react';
const createFlagIcon = (url: string) => {
return function (_props: LucideProps) {
return (
<span
className={`fi !block aspect-[1.33] overflow-hidden rounded-[2px] fi-${url}`}
></span>
);
} as LucideIcon;
};
const data = {
ie: createFlagIcon('ie'),
tw: createFlagIcon('tw'),
py: createFlagIcon('py'),
kr: createFlagIcon('kr'),
nz: createFlagIcon('nz'),
do: createFlagIcon('do'),
cl: createFlagIcon('cl'),
dz: createFlagIcon('dz'),
np: createFlagIcon('np'),
ma: createFlagIcon('ma'),
gh: createFlagIcon('gh'),
zm: createFlagIcon('zm'),
pa: createFlagIcon('pa'),
tn: createFlagIcon('tn'),
lk: createFlagIcon('lk'),
sv: createFlagIcon('sv'),
ve: createFlagIcon('ve'),
sn: createFlagIcon('sn'),
gt: createFlagIcon('gt'),
xk: createFlagIcon('xk'),
jm: createFlagIcon('jm'),
cm: createFlagIcon('cm'),
ni: createFlagIcon('ni'),
uy: createFlagIcon('uy'),
ss: createFlagIcon('ss'),
cd: createFlagIcon('cd'),
cu: createFlagIcon('cu'),
kh: createFlagIcon('kh'),
bb: createFlagIcon('bb'),
gf: createFlagIcon('gf'),
et: createFlagIcon('et'),
pe: createFlagIcon('pe'),
mo: createFlagIcon('mo'),
mn: createFlagIcon('mn'),
hn: createFlagIcon('hn'),
cn: createFlagIcon('cn'),
ng: createFlagIcon('ng'),
se: createFlagIcon('se'),
jp: createFlagIcon('jp'),
hk: createFlagIcon('hk'),
us: createFlagIcon('us'),
gb: createFlagIcon('gb'),
ua: createFlagIcon('ua'),
ru: createFlagIcon('ru'),
de: createFlagIcon('de'),
fr: createFlagIcon('fr'),
br: createFlagIcon('br'),
in: createFlagIcon('in'),
it: createFlagIcon('it'),
es: createFlagIcon('es'),
pl: createFlagIcon('pl'),
nl: createFlagIcon('nl'),
id: createFlagIcon('id'),
tr: createFlagIcon('tr'),
ph: createFlagIcon('ph'),
ca: createFlagIcon('ca'),
ar: createFlagIcon('ar'),
mx: createFlagIcon('mx'),
za: createFlagIcon('za'),
au: createFlagIcon('au'),
co: createFlagIcon('co'),
ch: createFlagIcon('ch'),
at: createFlagIcon('at'),
be: createFlagIcon('be'),
pt: createFlagIcon('pt'),
my: createFlagIcon('my'),
th: createFlagIcon('th'),
vn: createFlagIcon('vn'),
sg: createFlagIcon('sg'),
eg: createFlagIcon('eg'),
sa: createFlagIcon('sa'),
pk: createFlagIcon('pk'),
bd: createFlagIcon('bd'),
ro: createFlagIcon('ro'),
hu: createFlagIcon('hu'),
cz: createFlagIcon('cz'),
gr: createFlagIcon('gr'),
il: createFlagIcon('il'),
no: createFlagIcon('no'),
fi: createFlagIcon('fi'),
dk: createFlagIcon('dk'),
sk: createFlagIcon('sk'),
bg: createFlagIcon('bg'),
hr: createFlagIcon('hr'),
rs: createFlagIcon('rs'),
ba: createFlagIcon('ba'),
si: createFlagIcon('si'),
lv: createFlagIcon('lv'),
lt: createFlagIcon('lt'),
ee: createFlagIcon('ee'),
by: createFlagIcon('by'),
md: createFlagIcon('md'),
kz: createFlagIcon('kz'),
uz: createFlagIcon('uz'),
kg: createFlagIcon('kg'),
tj: createFlagIcon('tj'),
tm: createFlagIcon('tm'),
az: createFlagIcon('az'),
ge: createFlagIcon('ge'),
am: createFlagIcon('am'),
af: createFlagIcon('af'),
ir: createFlagIcon('ir'),
iq: createFlagIcon('iq'),
sy: createFlagIcon('sy'),
lb: createFlagIcon('lb'),
jo: createFlagIcon('jo'),
ps: createFlagIcon('ps'),
kw: createFlagIcon('kw'),
qa: createFlagIcon('qa'),
om: createFlagIcon('om'),
ye: createFlagIcon('ye'),
ae: createFlagIcon('ae'),
bh: createFlagIcon('bh'),
cy: createFlagIcon('cy'),
mt: createFlagIcon('mt'),
sm: createFlagIcon('sm'),
li: createFlagIcon('li'),
is: createFlagIcon('is'),
al: createFlagIcon('al'),
mk: createFlagIcon('mk'),
me: createFlagIcon('me'),
ad: createFlagIcon('ad'),
lu: createFlagIcon('lu'),
mc: createFlagIcon('mc'),
fo: createFlagIcon('fo'),
gg: createFlagIcon('gg'),
je: createFlagIcon('je'),
im: createFlagIcon('im'),
gi: createFlagIcon('gi'),
va: createFlagIcon('va'),
ax: createFlagIcon('ax'),
bl: createFlagIcon('bl'),
mf: createFlagIcon('mf'),
pm: createFlagIcon('pm'),
yt: createFlagIcon('yt'),
wf: createFlagIcon('wf'),
tf: createFlagIcon('tf'),
re: createFlagIcon('re'),
sc: createFlagIcon('sc'),
mu: createFlagIcon('mu'),
zw: createFlagIcon('zw'),
mz: createFlagIcon('mz'),
na: createFlagIcon('na'),
bw: createFlagIcon('bw'),
ls: createFlagIcon('ls'),
sz: createFlagIcon('sz'),
bi: createFlagIcon('bi'),
rw: createFlagIcon('rw'),
ug: createFlagIcon('ug'),
ke: createFlagIcon('ke'),
tz: createFlagIcon('tz'),
mg: createFlagIcon('mg'),
cr: createFlagIcon('cr'),
ky: createFlagIcon('ky'),
gy: createFlagIcon('gy'),
mm: createFlagIcon('mm'),
la: createFlagIcon('la'),
gl: createFlagIcon('gl'),
gp: createFlagIcon('gp'),
fj: createFlagIcon('fj'),
cv: createFlagIcon('cv'),
gn: createFlagIcon('gn'),
bj: createFlagIcon('bj'),
bo: createFlagIcon('bo'),
bq: createFlagIcon('bq'),
bs: createFlagIcon('bs'),
ly: createFlagIcon('ly'),
bn: createFlagIcon('bn'),
tt: createFlagIcon('tt'),
sr: createFlagIcon('sr'),
ec: createFlagIcon('ec'),
mv: createFlagIcon('mv'),
pr: createFlagIcon('pr'),
ci: createFlagIcon('ci'),
};
export default data;

View File

@@ -0,0 +1,99 @@
import { useMemo } from 'react';
import type { LucideIcon, LucideProps } from 'lucide-react';
import {
ActivityIcon,
ExternalLinkIcon,
HelpCircleIcon,
MailIcon,
MessageCircleIcon,
MonitorIcon,
MonitorPlayIcon,
PodcastIcon,
ScanIcon,
SearchIcon,
SmartphoneIcon,
TabletIcon,
TvIcon,
} from 'lucide-react';
import { NOT_SET_VALUE } from '@openpanel/constants';
import flags from './serie-icon.flags';
import iconsWithUrls from './serie-icon.urls';
type SerieIconProps = Omit<LucideProps, 'name'> & {
name?: string | string[];
};
function getProxyImage(url: string) {
return `${String(process.env.NEXT_PUBLIC_API_URL)}/misc/favicon?url=${encodeURIComponent(url)}`;
}
const createImageIcon = (url: string) => {
return function (_props: LucideProps) {
return <img className="max-h-4 rounded-[2px] object-contain" src={url} />;
} as LucideIcon;
};
const mapper: Record<string, LucideIcon> = {
// Events
screen_view: MonitorPlayIcon,
session_start: ActivityIcon,
session_end: ActivityIcon,
link_out: ExternalLinkIcon,
// Misc
smarttv: TvIcon,
mobile: SmartphoneIcon,
desktop: MonitorIcon,
tablet: TabletIcon,
search: SearchIcon,
social: PodcastIcon,
email: MailIcon,
podcast: PodcastIcon,
comment: MessageCircleIcon,
unknown: HelpCircleIcon,
[NOT_SET_VALUE]: ScanIcon,
...Object.entries(iconsWithUrls).reduce(
(acc, [key, value]) => ({
...acc,
[key]: createImageIcon(getProxyImage(value)),
}),
{}
),
...flags,
};
export function SerieIcon({ name: names, ...props }: SerieIconProps) {
const name = Array.isArray(names) ? names[0] : names;
const Icon = useMemo(() => {
if (!name) {
return null;
}
const mapped = mapper[name.toLowerCase()] ?? null;
if (mapped) {
return mapped;
}
if (name.includes('http')) {
return createImageIcon(getProxyImage(name));
}
// Matching image file name
if (name.match(/(.+)\.\w{2,3}$/)) {
return createImageIcon(getProxyImage(`https://${name}`));
}
return null;
}, [name]);
return Icon ? (
<div className="relative max-h-4 flex-shrink-0 [&_svg]:!rounded-[2px]">
<Icon size={16} {...props} name={name} />
</div>
) : null;
}

View File

@@ -0,0 +1,78 @@
// prettier-ignore
const data = {
'chromium os': 'https://upload.wikimedia.org/wikipedia/commons/2/28/Chromium_Logo.svg',
'mac os': 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/30/MacOS_logo.svg/1200px-MacOS_logo.svg.png',
'apple': 'https://sladesportfolio.wordpress.com/wp-content/uploads/2015/08/apple_logo_black-svg.png',
'huawei': 'https://upload.wikimedia.org/wikipedia/en/0/04/Huawei_Standard_logo.svg',
'xiaomi': 'https://upload.wikimedia.org/wikipedia/commons/2/29/Xiaomi_logo.svg',
'sony': 'https://serialtrainer7.com/wp-content/uploads/2021/07/sony-logo-300px-square.png',
'lg': 'https://upload.wikimedia.org/wikipedia/commons/2/20/LG_symbol.svg',
'samsung': 'https://seekvectors.com/storage/images/Samsung-Logo-22.svg',
'oppo': 'https://indoleads.nyc3.cdn.digitaloceanspaces.com/uploads/offers/logos/8695_95411e367b832.png',
'motorola': 'https://upload.wikimedia.org/wikipedia/commons/8/8f/Motorola_M_symbol_blue.svg',
'oneplus': 'https://pbs.twimg.com/profile_images/1709165009148809216/ebHb4xhF_400x400.png',
'asus': 'https://cdn-icons-png.freepik.com/512/5969/5969050.png',
'fairphone': 'https://cdn.dribbble.com/users/433772/screenshots/2109827/fairphone_dribbble.jpg',
'nokia': 'https://www.gizchina.com/wp-content/uploads/images/2023/02/Nokia-logo.webp',
'mobile safari': 'https://upload.wikimedia.org/wikipedia/commons/5/52/Safari_browser_logo.svg',
'openpanel.dev': 'https://openpanel.dev',
'samsung internet': 'https://upload.wikimedia.org/wikipedia/commons/thumb/e/e9/Samsung_Internet_logo.svg/1024px-Samsung_Internet_logo.svg.png',
'vstat.info': 'https://vstat.info',
'yahoo!': 'https://yahoo.com',
android: 'https://image.similarpng.com/very-thumbnail/2020/08/Android-icon-on-transparent--background-PNG.png',
'android browser': 'https://image.similarpng.com/very-thumbnail/2020/08/Android-icon-on-transparent--background-PNG.png',
'silk': 'https://m.media-amazon.com/images/I/51VCjQCvF0L.png',
'kakaotalk': 'https://www.kakaocorp.com/',
bing: 'https://bing.com',
'electron': 'https://www.electronjs.org',
'whale': 'https://whale.naver.com',
'wechat': 'https://wechat.com',
chrome: 'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg',
'chrome webview': 'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg',
'chrome headless': 'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg',
chromecast: 'https://upload.wikimedia.org/wikipedia/commons/archive/2/26/20161130022732%21Chromecast_cast_button_icon.svg',
webkit: 'https://webkit.org',
duckduckgo: 'https://duckduckgo.com',
ecosia: 'https://ecosia.com',
edge: 'https://upload.wikimedia.org/wikipedia/commons/7/7e/Microsoft_Edge_logo_%282019%29.png',
facebook: 'https://facebook.com',
firefox: 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a0/Firefox_logo%2C_2019.svg/1920px-Firefox_logo%2C_2019.svg.png',
github: 'https://github.com',
gmail: 'https://mail.google.com',
google: 'https://google.com',
gsa: 'https://google.com', // Google Search App
instagram: 'https://instagram.com',
ios: 'https://cdn0.iconfinder.com/data/icons/flat-round-system/512/apple-1024.png',
linkedin: 'https://linkedin.com',
linux: 'https://upload.wikimedia.org/wikipedia/commons/3/35/Tux.svg',
ubuntu: 'https://upload.wikimedia.org/wikipedia/commons/a/ab/Logo-ubuntu_cof-orange-hex.svg',
weibo: 'https://en.wikipedia.org/wiki/Weibo#/media/File:Sina_Weibo.svg',
microlaunch: 'https://microlaunch.net',
openalternative: 'https://openalternative.co',
opera: 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/49/Opera_2015_icon.svg/1920px-Opera_2015_icon.svg.png',
pinterest: 'https://www.pinterest.se',
producthunt: 'https://www.producthunt.com',
reddit: 'https://reddit.com',
safari: 'https://upload.wikimedia.org/wikipedia/commons/5/52/Safari_browser_logo.svg',
slack: 'https://slack.com',
snapchat: 'https://snapchat.com',
taaft: 'https://theresanaiforthat.com',
twitter: 'https://twitter.com',
windows: 'https://upload.wikimedia.org/wikipedia/commons/c/c7/Windows_logo_-_2012.png',
yandex: 'https://yandex.com',
youtube: 'https://youtube.com',
ossgallery: 'https://oss.gallery',
convertkit: 'https://convertkit.com',
whatsapp: 'https://www.whatsapp.com/',
telegram: 'https://telegram.org/',
tiktok: 'https://tiktok.com',
sharpspring: 'https://sharpspring.com',
'hacker news': 'https://news.ycombinator.com',
'betalist': 'https://betalist.com',
'qwant': 'https://www.qwant.com',
flipboard: 'https://flipboard.com/',
trustpilot: 'https://trustpilot.com',
'outlook.com': 'https://login.live.com/',
};
export default data;

View File

@@ -0,0 +1,43 @@
import { cn } from '@/utils/cn';
import { ChevronRightIcon } from 'lucide-react';
import { NOT_SET_VALUE } from '@openpanel/constants';
import { useReportChartContext } from '../context';
interface SerieNameProps {
name: string | string[];
className?: string;
}
export function SerieName({ name, className }: SerieNameProps) {
const {
options: { renderSerieName },
} = useReportChartContext();
if (Array.isArray(name)) {
if (renderSerieName) {
return renderSerieName(name);
}
return (
<div className={cn('flex items-center gap-1', className)}>
{name.map((n, index) => {
return (
<>
<span>{n || NOT_SET_VALUE}</span>
{name.length - 1 > index && (
<ChevronRightIcon className="text-muted-foreground" size={12} />
)}
</>
);
})}
</div>
);
}
if (renderSerieName) {
return renderSerieName([name]);
}
return <>{name}</>;
}

View File

@@ -0,0 +1,84 @@
import { createContext, useContext, useEffect, useState } from 'react';
import isEqual from 'lodash.isequal';
import type { LucideIcon } from 'lucide-react';
import type {
IChartInput,
IChartProps,
IChartSerie,
} from '@openpanel/validation';
export type ReportChartContextType = {
options: Partial<{
hideID: boolean;
hideLegend: boolean;
hideXAxis: boolean;
hideYAxis: boolean;
aspectRatio: number;
maxHeight: number;
minHeight: number;
maxDomain: number;
onClick: (serie: IChartSerie) => void;
renderSerieName: (names: string[]) => React.ReactNode;
renderSerieIcon: (serie: IChartSerie) => React.ReactNode;
dropdownMenuContent: (serie: IChartSerie) => {
icon: LucideIcon;
title: string;
onClick: () => void;
}[];
}>;
report: IChartProps;
isLazyLoading: boolean;
isEditMode: boolean;
};
type ReportChartContextProviderProps = ReportChartContextType & {
children: React.ReactNode;
};
export type ReportChartProps = Partial<ReportChartContextType> & {
report: IChartInput;
};
const context = createContext<ReportChartContextType | null>(null);
export const useReportChartContext = () => {
const ctx = useContext(context);
if (!ctx) {
throw new Error(
'useReportChartContext must be used within a ReportChartProvider'
);
}
return ctx;
};
export const useSelectReportChartContext = <T,>(
selector: (ctx: ReportChartContextType) => T
) => {
const ctx = useReportChartContext();
const [state, setState] = useState(selector(ctx));
useEffect(() => {
const newState = selector(ctx);
if (!isEqual(newState, state)) {
setState(newState);
}
}, [ctx]);
return state;
};
export const ReportChartProvider = ({
children,
...propsToContext
}: ReportChartContextProviderProps) => {
const [ctx, setContext] = useState(propsToContext);
useEffect(() => {
if (!isEqual(ctx, propsToContext)) {
setContext(propsToContext);
}
}, [propsToContext]);
return <context.Provider value={ctx}>{children}</context.Provider>;
};
export default context;

View File

@@ -0,0 +1,239 @@
'use client';
import { ColorSquare } from '@/components/color-square';
import { TooltipComplete } from '@/components/tooltip-complete';
import { Progress } from '@/components/ui/progress';
import { Widget, WidgetBody } from '@/components/widget';
import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import { AlertCircleIcon } from 'lucide-react';
import { last } from 'ramda';
import { getPreviousMetric } from '@openpanel/common';
import { alphabetIds } from '@openpanel/constants';
import { PreviousDiffIndicator } from '../common/previous-diff-indicator';
import { useReportChartContext } from '../context';
const findMostDropoffs = (
steps: RouterOutputs['chart']['funnel']['current']['steps']
) => {
return steps.reduce((acc, step) => {
if (step.dropoffCount > acc.dropoffCount) {
return step;
}
return acc;
});
};
type Props = {
data: RouterOutputs['chart']['funnel'];
};
export function Chart({
data: {
current: { steps, totalSessions },
previous,
},
}: Props) {
const { isEditMode } = useReportChartContext();
const mostDropoffs = findMostDropoffs(steps);
const lastStep = last(steps)!;
const prevLastStep = last(previous.steps);
const withWidget = (children: React.ReactNode) => {
if (isEditMode) {
return (
<Widget>
<WidgetBody>{children}</WidgetBody>
</Widget>
);
}
return children;
};
return withWidget(
<div className="flex flex-col gap-4 @container">
<div
className={cn(
'border border-border',
!isEditMode && 'border-0 border-b'
)}
>
<div className="flex items-center gap-8 p-4">
<div className="hidden shrink-0 gap-2 @xl:flex">
{steps.map((step) => {
return (
<div
className="flex h-20 w-8 items-end overflow-hidden rounded bg-def-200"
key={step.event.id}
>
<div
className="w-full bg-def-400"
style={{ height: `${step.percent}%` }}
></div>
</div>
);
})}
</div>
<div className="flex flex-1 items-center gap-4">
<div className="flex flex-1 flex-col">
<div className="text-2xl">
<span className="font-bold">
{lastStep.count} of {totalSessions}
</span>{' '}
sessions{' '}
</div>
<div className="text-xl text-muted-foreground">
Last period:{' '}
<span className="font-semibold">
{prevLastStep?.count} of {previous.totalSessions}
</span>
</div>
</div>
<PreviousDiffIndicator
size="lg"
{...getPreviousMetric(lastStep.count, prevLastStep?.count)}
/>
</div>
</div>
</div>
<div className="flex flex-col gap-1 divide-y">
{steps.map((step, index) => {
const percent = (step.count / totalSessions) * 100;
const isMostDropoffs = mostDropoffs.event.id === step.event.id;
return (
<div
key={step.event.id}
className="flex flex-col gap-4 px-4 py-4 @2xl:flex-row @2xl:px-8"
>
<div className="relative flex flex-1 flex-col gap-2 pl-8">
<ColorSquare className="absolute left-0 top-0.5">
{alphabetIds[index]}
</ColorSquare>
<div className="font-semibold capitalize">
{step.event.displayName.replace(/_/g, ' ')}
</div>
<div className="flex items-center gap-8 text-sm">
<TooltipComplete
disabled={!previous.steps[index]}
content={
<div className="flex items-center gap-2">
<span>
Last period:{' '}
<span className="font-semibold">
{previous.steps[index]?.previousCount}
</span>
</span>
<PreviousDiffIndicator
{...getPreviousMetric(
step.previousCount,
previous.steps[index]?.previousCount
)}
/>
</div>
}
>
<div className="flex flex-col gap-2">
<span className="text-xs text-muted-foreground">
Total:
</span>
<div className="flex items-center gap-4">
<span className="text-lg font-bold">
{step.previousCount}
</span>
</div>
</div>
</TooltipComplete>
<TooltipComplete
disabled={!previous.steps[index]}
content={
<div className="flex items-center gap-2">
<span>
Last period:{' '}
<span className="font-semibold">
{previous.steps[index]?.dropoffCount}
</span>
</span>
<PreviousDiffIndicator
inverted
{...getPreviousMetric(
step.dropoffCount,
previous.steps[index]?.dropoffCount
)}
/>
</div>
}
>
<div className="flex flex-col gap-2">
<span className="text-xs text-muted-foreground">
Dropoff:
</span>
<div className="flex items-center gap-4">
<span
className={cn(
'flex items-center gap-1 text-lg font-bold',
isMostDropoffs && 'text-rose-500'
)}
>
{isMostDropoffs && <AlertCircleIcon size={14} />}
{step.dropoffCount}
</span>
</div>
</div>
</TooltipComplete>
<TooltipComplete
disabled={!previous.steps[index]}
content={
<div className="flex items-center gap-2">
<span>
Last period:{' '}
<span className="font-semibold">
{previous.steps[index]?.count}
</span>
</span>
<PreviousDiffIndicator
{...getPreviousMetric(
step.count,
previous.steps[index]?.count
)}
/>
</div>
}
>
<div className="flex flex-col gap-2">
<span className="text-xs text-muted-foreground">
Current:
</span>
<div className="flex items-center gap-4">
<span className="text-lg font-bold">{step.count}</span>
{/* <button
className="ml-2 underline"
onClick={() =>
pushModal('FunnelStepDetails', {
...input,
step: index + 1,
})
}
>
Inspect
</button> */}
</div>
</div>
</TooltipComplete>
</div>
</div>
<Progress
size="lg"
className="w-full @2xl:w-1/2"
color={getChartColor(index)}
value={percent}
/>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
'use client';
import { api } from '@/trpc/client';
import type { IChartInput } from '@openpanel/validation';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
import { ReportChartLoading } from '../common/loading';
import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportFunnelChart() {
const {
report: { events, range, projectId },
isLazyLoading,
} = useReportChartContext();
const input: IChartInput = {
events,
range,
projectId,
interval: 'day',
chartType: 'funnel',
breakdowns: [],
previous: false,
metric: 'sum',
};
const res = api.chart.funnel.useQuery(input, {
keepPreviousData: true,
});
if (isLazyLoading || res.isLoading || res.isFetching) {
return <Loading />;
}
if (res.isError) {
return <Error />;
}
if (res.data.current.steps.length === 0) {
return <Empty />;
}
return <Chart data={res.data} />;
}
function Loading() {
return (
<AspectContainer>
<ReportChartLoading />
</AspectContainer>
);
}
function Error() {
return (
<AspectContainer>
<ReportChartError />
</AspectContainer>
);
}
function Empty() {
return (
<AspectContainer>
<ReportChartEmpty />
</AspectContainer>
);
}

View File

@@ -0,0 +1,130 @@
import React from 'react';
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { getChartColor, theme } from '@/utils/theme';
import { useTheme } from 'next-themes';
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { useXAxisProps, useYAxisProps } from '../common/axis';
import { ReportChartTooltip } from '../common/report-chart-tooltip';
import { ReportTable } from '../common/report-table';
import { useReportChartContext } from '../context';
interface Props {
data: IChartData;
}
function BarHover({ x, y, width, height, top, left, right, bottom }: any) {
const themeMode = useTheme();
const bg =
themeMode?.theme === 'dark'
? theme.colors['def-100']
: theme.colors['def-300'];
return (
<rect
{...{ x, y, width, height, top, left, right, bottom }}
rx="3"
fill={bg}
fillOpacity={0.5}
/>
);
}
export function Chart({ data }: Props) {
const {
isEditMode,
report: { previous, interval },
options: { hideXAxis, hideYAxis },
} = useReportChartContext();
const { series, setVisibleSeries } = useVisibleSeries(data);
const rechartData = useRechartDataModel(series);
const yAxisProps = useYAxisProps({
data: [data.metrics.max],
hide: hideYAxis,
});
const xAxisProps = useXAxisProps({
hide: hideXAxis,
interval,
});
return (
<>
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
<ResponsiveContainer>
<BarChart data={rechartData} barCategoryGap={10}>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
className="stroke-def-200"
/>
<Tooltip content={<ReportChartTooltip />} cursor={<BarHover />} />
<YAxis {...yAxisProps} />
<XAxis {...xAxisProps} />
{series.map((serie) => {
return (
<React.Fragment key={serie.id}>
<defs>
<linearGradient
id="colorGradient"
x1="0"
y1="1"
x2="0"
y2="0"
>
<stop
offset="0%"
stopColor={getChartColor(serie.index)}
stopOpacity={0.7}
/>
<stop
offset="100%"
stopColor={getChartColor(serie.index)}
stopOpacity={1}
/>
</linearGradient>
</defs>
{previous && (
<Bar
key={`${serie.id}:prev`}
name={`${serie.id}:prev`}
dataKey={`${serie.id}:prev:count`}
fill={getChartColor(serie.index)}
fillOpacity={0.1}
radius={3}
barSize={20} // Adjust the bar width here
/>
)}
<Bar
key={serie.id}
name={serie.id}
dataKey={`${serie.id}:count`}
fill="url(#colorGradient)"
radius={3}
fillOpacity={1}
barSize={20} // Adjust the bar width here
/>
</React.Fragment>
);
})}
</BarChart>
</ResponsiveContainer>
</div>
{isEditMode && (
<ReportTable
data={data}
visibleSeries={series}
setVisibleSeries={setVisibleSeries}
/>
)}
</>
);
}

View File

@@ -0,0 +1,59 @@
import { api } from '@/trpc/client';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
import { ReportChartLoading } from '../common/loading';
import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportHistogramChart() {
const { isLazyLoading, report } = useReportChartContext();
const res = api.chart.chart.useQuery(report, {
keepPreviousData: true,
staleTime: 1000 * 60 * 1,
});
if (isLazyLoading || res.isLoading || res.isFetching) {
return <Loading />;
}
if (res.isError) {
return <Error />;
}
if (res.data.series.length === 0) {
return <Empty />;
}
return (
<AspectContainer>
<Chart data={res.data} />
</AspectContainer>
);
}
function Loading() {
return (
<AspectContainer>
<ReportChartLoading />
</AspectContainer>
);
}
function Error() {
return (
<AspectContainer>
<ReportChartError />
</AspectContainer>
);
}
function Empty() {
return (
<AspectContainer>
<ReportChartEmpty />
</AspectContainer>
);
}

View File

@@ -0,0 +1,66 @@
'use client';
import React, { useEffect, useRef } from 'react';
import { mergeDeepRight } from 'ramda';
import { useInViewport } from 'react-in-viewport';
import { ReportAreaChart } from './area';
import { ReportBarChart } from './bar';
import type { ReportChartProps } from './context';
import { ReportChartProvider } from './context';
import { ReportFunnelChart } from './funnel';
import { ReportHistogramChart } from './histogram';
import { ReportLineChart } from './line';
import { ReportMapChart } from './map';
import { ReportMetricChart } from './metric';
import { ReportPieChart } from './pie';
export function ReportChart(props: ReportChartProps) {
const ref = useRef<HTMLDivElement>(null);
const once = useRef(false);
const { inViewport } = useInViewport(ref, undefined, {
disconnectOnLeave: true,
});
useEffect(() => {
if (inViewport) {
once.current = true;
}
}, [inViewport]);
const loaded = once.current || inViewport;
const renderReportChart = () => {
switch (props.report.chartType) {
case 'linear':
return <ReportLineChart />;
case 'bar':
return <ReportBarChart />;
case 'area':
return <ReportAreaChart />;
case 'histogram':
return <ReportHistogramChart />;
case 'pie':
return <ReportPieChart />;
case 'map':
return <ReportMapChart />;
case 'metric':
return <ReportMetricChart />;
case 'funnel':
return <ReportFunnelChart />;
default:
return null;
}
};
return (
<div ref={ref}>
<ReportChartProvider
{...mergeDeepRight({ options: {}, isEditMode: false }, props)}
isLazyLoading={!loaded}
>
{renderReportChart()}
</ReportChartProvider>
</div>
);
}

View File

@@ -0,0 +1,279 @@
'use client';
import React, { useCallback } from 'react';
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { api } from '@/trpc/client';
import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import { isSameDay, isSameHour, isSameMonth } from 'date-fns';
import { last } from 'ramda';
import {
Area,
CartesianGrid,
ComposedChart,
Legend,
Line,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { useXAxisProps, useYAxisProps } from '../common/axis';
import { ReportChartTooltip } from '../common/report-chart-tooltip';
import { ReportTable } from '../common/report-table';
import { SerieIcon } from '../common/serie-icon';
import { SerieName } from '../common/serie-name';
import { useReportChartContext } from '../context';
interface Props {
data: IChartData;
}
export function Chart({ data }: Props) {
const {
report: {
previous,
interval,
projectId,
startDate,
endDate,
range,
lineType,
},
isEditMode,
options: { hideXAxis, hideYAxis, maxDomain },
} = useReportChartContext();
const dataLength = data.series[0]?.data?.length || 0;
const references = api.reference.getChartReferences.useQuery(
{
projectId,
startDate,
endDate,
range,
},
{
staleTime: 1000 * 60 * 10,
}
);
const { series, setVisibleSeries } = useVisibleSeries(data);
const rechartData = useRechartDataModel(series);
// great care should be taken when computing lastIntervalPercent
// the expression below works for data.length - 1 equal intervals
// but if there are numeric x values in a "linear" axis, the formula
// should be updated to use those values
const lastIntervalPercent =
((rechartData.length - 2) * 100) / (rechartData.length - 1);
const gradientTwoColors = (
id: string,
col1: string,
col2: string,
percentChange: number
) => (
<linearGradient id={id} x1="0" y1="0" x2="100%" y2="0">
<stop offset="0%" stopColor={col1} />
<stop offset={`${percentChange}%`} stopColor={col1} />
<stop offset={`${percentChange}%`} stopColor={`${col2}`} />
<stop offset="100%" stopColor={col2} />
</linearGradient>
);
const lastSerieDataItem = last(series[0]?.data || [])?.date || new Date();
const useDashedLastLine = (() => {
if (interval === 'hour') {
return isSameHour(lastSerieDataItem, new Date());
}
if (interval === 'day') {
return isSameDay(lastSerieDataItem, new Date());
}
if (interval === 'month') {
return isSameMonth(lastSerieDataItem, new Date());
}
return false;
})();
const CustomLegend = useCallback(() => {
return (
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1">
{series.map((serie) => (
<div
className="flex items-center gap-1"
key={serie.id}
style={{
color: getChartColor(serie.index),
}}
>
<SerieIcon name={serie.names} />
<SerieName name={serie.names} />
</div>
))}
</div>
);
}, [series]);
const isAreaStyle = series.length === 1;
const xAxisProps = useXAxisProps({ interval, hide: hideXAxis });
const yAxisProps = useYAxisProps({
data: [data.metrics.max],
hide: hideYAxis,
});
return (
<>
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
<ResponsiveContainer>
<ComposedChart data={rechartData}>
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}
vertical={false}
className="stroke-border"
/>
{references.data?.map((ref) => (
<ReferenceLine
key={ref.id}
x={ref.date.getTime()}
stroke={'#94a3b8'}
strokeDasharray={'3 3'}
label={{
value: ref.title,
position: 'centerTop',
fill: '#334155',
fontSize: 12,
}}
fontSize={10}
/>
))}
<YAxis
{...yAxisProps}
domain={maxDomain ? [0, maxDomain] : undefined}
/>
<XAxis {...xAxisProps} />
{series.length > 1 && (
<Legend
wrapperStyle={{ fontSize: '10px' }}
content={<CustomLegend />}
/>
)}
<Tooltip content={<ReportChartTooltip />} />
{series.map((serie) => {
const color = getChartColor(serie.index);
return (
<React.Fragment key={serie.id}>
<defs>
{isAreaStyle && (
<linearGradient
id={`color${color}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={color}
stopOpacity={0.8}
></stop>
<stop
offset="100%"
stopColor={color}
stopOpacity={0.1}
></stop>
</linearGradient>
)}
{gradientTwoColors(
`hideAllButLastInterval_${serie.id}`,
'rgba(0,0,0,0)',
color,
lastIntervalPercent
)}
{gradientTwoColors(
`hideJustLastInterval_${serie.id}`,
color,
'rgba(0,0,0,0)',
lastIntervalPercent
)}
</defs>
<Line
dot={isAreaStyle && dataLength <= 8}
type={lineType}
name={serie.id}
isAnimationActive={false}
strokeWidth={2}
dataKey={`${serie.id}:count`}
stroke={useDashedLastLine ? 'transparent' : color}
// Use for legend
fill={color}
/>
{isAreaStyle && (
<Area
dot={false}
name={`${serie.id}:area:noTooltip`}
dataKey={`${serie.id}:count`}
fill={`url(#color${color})`}
type={lineType}
isAnimationActive={false}
fillOpacity={0.1}
/>
)}
{useDashedLastLine && (
<>
<Line
dot={false}
type={lineType}
name={`${serie.id}:dashed:noTooltip`}
isAnimationActive={false}
strokeWidth={2}
dataKey={`${serie.id}:count`}
stroke={`url('#hideAllButLastInterval_${serie.id}')`}
strokeDasharray="4 2"
strokeOpacity={0.7}
/>
<Line
dot={false}
type={lineType}
name={`${serie.id}:solid:noTooltip`}
isAnimationActive={false}
strokeWidth={2}
dataKey={`${serie.id}:count`}
stroke={`url('#hideJustLastInterval_${serie.id}')`}
/>
</>
)}
{previous && (
<Line
type={lineType}
name={`${serie.id}:prev`}
isAnimationActive
dot={false}
strokeOpacity={0.3}
dataKey={`${serie.id}:prev:count`}
stroke={color}
// Use for legend
fill={color}
/>
)}
</React.Fragment>
);
})}
</ComposedChart>
</ResponsiveContainer>
</div>
{isEditMode && (
<ReportTable
data={data}
visibleSeries={series}
setVisibleSeries={setVisibleSeries}
/>
)}
</>
);
}

View File

@@ -0,0 +1,59 @@
import { api } from '@/trpc/client';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
import { ReportChartLoading } from '../common/loading';
import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportLineChart() {
const { isLazyLoading, report } = useReportChartContext();
const res = api.chart.chart.useQuery(report, {
keepPreviousData: true,
staleTime: 1000 * 60 * 1,
});
if (isLazyLoading || res.isLoading || res.isFetching) {
return <Loading />;
}
if (res.isError) {
return <Error />;
}
if (res.data.series.length === 0) {
return <Empty />;
}
return (
<AspectContainer>
<Chart data={res.data} />
</AspectContainer>
);
}
function Loading() {
return (
<AspectContainer>
<ReportChartLoading />
</AspectContainer>
);
}
function Error() {
return (
<AspectContainer>
<ReportChartError />
</AspectContainer>
);
}
function Empty() {
return (
<AspectContainer>
<ReportChartEmpty />
</AspectContainer>
);
}

View File

@@ -0,0 +1,42 @@
import { useMemo } from 'react';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import type { IChartData } from '@/trpc/client';
import { theme } from '@/utils/theme';
import WorldMap from 'react-svg-worldmap';
import AutoSizer from 'react-virtualized-auto-sizer';
import { useReportChartContext } from '../context';
interface Props {
data: IChartData;
}
export function Chart({ data }: Props) {
const {
report: { metric, unit },
} = useReportChartContext();
const { series } = useVisibleSeries(data, 100);
const mapData = useMemo(
() =>
series.map((s) => ({
country: s.names[0]?.toLowerCase() ?? '',
value: s.metrics[metric],
})),
[series, metric]
);
return (
<AutoSizer disableHeight>
{({ width }) => (
<WorldMap
size={width}
data={mapData}
color={theme.colors['chart-0']}
borderColor={'#103A96'}
value-suffix={unit}
/>
)}
</AutoSizer>
);
}

View File

@@ -0,0 +1,55 @@
import { api } from '@/trpc/client';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
import { ReportChartLoading } from '../common/loading';
import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportMapChart() {
const { isLazyLoading, report } = useReportChartContext();
const res = api.chart.chart.useQuery(report, {
keepPreviousData: true,
staleTime: 1000 * 60 * 1,
});
if (isLazyLoading || res.isLoading || res.isFetching) {
return <Loading />;
}
if (res.isError) {
return <Error />;
}
if (res.data.series.length === 0) {
return <Empty />;
}
return <Chart data={res.data} />;
}
function Loading() {
return (
<AspectContainer>
<ReportChartLoading />
</AspectContainer>
);
}
function Error() {
return (
<AspectContainer>
<ReportChartError />
</AspectContainer>
);
}
function Empty() {
return (
<AspectContainer>
<ReportChartEmpty />
</AspectContainer>
);
}

View File

@@ -0,0 +1,39 @@
'use client';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { useReportChartContext } from '../context';
import { MetricCard } from './metric-card';
interface Props {
data: IChartData;
}
export function Chart({ data }: Props) {
const {
isEditMode,
report: { metric, unit },
} = useReportChartContext();
const { series } = useVisibleSeries(data, isEditMode ? 20 : 4);
return (
<div
className={cn(
'grid grid-cols-1 gap-4',
isEditMode && 'md:grid-cols-2 lg:grid-cols-3'
)}
>
{series.map((serie) => {
return (
<MetricCard
key={serie.id}
serie={serie}
metric={metric}
unit={unit}
/>
);
})}
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { api } from '@/trpc/client';
import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportMetricChart() {
const { isLazyLoading, report } = useReportChartContext();
const res = api.chart.chart.useQuery(report, {
keepPreviousData: true,
staleTime: 1000 * 60 * 1,
});
if (isLazyLoading || res.isLoading || res.isFetching) {
return <Loading />;
}
if (res.isError) {
return <Error />;
}
if (res.data.series.length === 0) {
return <Empty />;
}
return <Chart data={res.data} />;
}
export function Loading() {
return (
<div className="flex h-[78px] flex-col justify-between p-4">
<div className="h-3 w-1/2 animate-pulse rounded bg-def-200"></div>
<div className="row items-end justify-between">
<div className="h-6 w-1/3 animate-pulse rounded bg-def-200"></div>
<div className="h-3 w-1/5 animate-pulse rounded bg-def-200"></div>
</div>
</div>
);
}
export function Error() {
return (
<div className="relative h-[70px]">
<div className="opacity-50">
<Loading />
</div>
<div className="center-center absolute inset-0 text-muted-foreground">
<div className="text-sm font-medium">Error fetching data</div>
</div>
</div>
);
}
export function Empty() {
return (
<div className="relative h-[70px]">
<div className="opacity-50">
<Loading />
</div>
<div className="center-center absolute inset-0 text-muted-foreground">
<div className="text-sm font-medium">No data</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,127 @@
'use client';
import { fancyMinutes, useNumber } from '@/hooks/useNumerFormatter';
import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn';
import AutoSizer from 'react-virtualized-auto-sizer';
import { Area, AreaChart } from 'recharts';
import type { IChartMetric } from '@openpanel/validation';
import {
getDiffIndicator,
PreviousDiffIndicator,
} from '../common/previous-diff-indicator';
import { SerieName } from '../common/serie-name';
import { useReportChartContext } from '../context';
interface MetricCardProps {
serie: IChartData['series'][number];
color?: string;
metric: IChartMetric;
unit?: string;
}
export function MetricCard({
serie,
color: _color,
metric,
unit,
}: MetricCardProps) {
const {
report: { previousIndicatorInverted },
isEditMode,
} = useReportChartContext();
const number = useNumber();
const renderValue = (value: number, unitClassName?: string) => {
if (unit === 'min') {
return <>{fancyMinutes(value)}</>;
}
return (
<>
{number.short(value)}
{unit && <span className={unitClassName}>{unit}</span>}
</>
);
};
const previous = serie.metrics.previous?.[metric];
const graphColors = getDiffIndicator(
previousIndicatorInverted,
previous?.state,
'#6ee7b7', // green
'#fda4af', // red
'#93c5fd' // blue
);
return (
<div
className={cn('group relative p-4', isEditMode && 'card h-auto')}
key={serie.id}
>
<div
className={cn(
'pointer-events-none absolute -left-1 -right-1 bottom-0 top-0 z-0 opacity-50 transition-opacity duration-300 group-hover:opacity-100'
)}
>
<AutoSizer>
{({ width, height }) => (
<AreaChart
width={width}
height={height / 4}
data={serie.data}
style={{ marginTop: (height / 4) * 3 }}
>
<defs>
<linearGradient
id={`colorUv${serie.id}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="0%" stopColor={graphColors} stopOpacity={0.2} />
<stop
offset="100%"
stopColor={graphColors}
stopOpacity={0.05}
/>
</linearGradient>
</defs>
<Area
dataKey="count"
type="step"
fill={`url(#colorUv${serie.id})`}
fillOpacity={1}
stroke={graphColors}
strokeWidth={1}
isAnimationActive={false}
/>
</AreaChart>
)}
</AutoSizer>
</div>
<div className="col relative gap-2">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2 text-left">
<span className="truncate text-muted-foreground">
<SerieName name={serie.names} />
</span>
</div>
</div>
<div className="flex items-end justify-between">
<div className="truncate font-mono text-3xl font-bold">
{renderValue(serie.metrics[metric], 'ml-1 font-light text-xl')}
</div>
<PreviousDiffIndicator
{...previous}
className="text-sm text-muted-foreground"
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,126 @@
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { round } from '@/utils/math';
import { getChartColor } from '@/utils/theme';
import { truncate } from '@/utils/truncate';
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
import { AXIS_FONT_PROPS } from '../common/axis';
import { ReportChartTooltip } from '../common/report-chart-tooltip';
import { ReportTable } from '../common/report-table';
import { useReportChartContext } from '../context';
interface Props {
data: IChartData;
}
export function Chart({ data }: Props) {
const { isEditMode } = useReportChartContext();
const { series, setVisibleSeries } = useVisibleSeries(data);
const sum = series.reduce((acc, serie) => acc + serie.metrics.sum, 0);
const pieData = series.map((serie) => ({
id: serie.id,
color: getChartColor(serie.index),
index: serie.index,
name: serie.names.join(' > '),
count: serie.metrics.sum,
percent: serie.metrics.sum / sum,
}));
return (
<>
<div
className={cn('h-full w-full max-sm:-mx-3', isEditMode && 'card p-4')}
>
<ResponsiveContainer>
<PieChart>
<Tooltip content={<ReportChartTooltip />} />
<Pie
dataKey={'count'}
data={pieData}
innerRadius={'50%'}
outerRadius={'80%'}
isAnimationActive={false}
label={renderLabel}
>
{pieData.map((item) => {
return (
<Cell
key={item.id}
strokeWidth={2}
stroke={item.color}
fill={item.color}
/>
);
})}
</Pie>
</PieChart>
</ResponsiveContainer>
</div>
{isEditMode && (
<ReportTable
data={data}
visibleSeries={series}
setVisibleSeries={setVisibleSeries}
/>
)}
</>
);
}
const renderLabel = ({
cx,
cy,
midAngle,
innerRadius,
outerRadius,
fill,
payload,
}: {
cx: number;
cy: number;
midAngle: number;
innerRadius: number;
outerRadius: number;
fill: string;
payload: { name: string; percent: number };
}) => {
const RADIAN = Math.PI / 180;
const radius = 25 + innerRadius + (outerRadius - innerRadius);
const radiusProcent = innerRadius + (outerRadius - innerRadius) * 0.5;
const xProcent = cx + radiusProcent * Math.cos(-midAngle * RADIAN);
const yProcent = cy + radiusProcent * Math.sin(-midAngle * RADIAN);
const x = cx + radius * Math.cos(-midAngle * RADIAN);
const y = cy + radius * Math.sin(-midAngle * RADIAN);
const name = payload.name;
const percent = round(payload.percent * 100, 1);
return (
<>
<text
x={xProcent}
y={yProcent}
fill="white"
textAnchor="middle"
dominantBaseline="central"
fontWeight={700}
pointerEvents={'none'}
{...AXIS_FONT_PROPS}
>
{percent}%
</text>
<text
x={x}
y={y}
fill={fill}
textAnchor={x > cx ? 'start' : 'end'}
dominantBaseline="central"
{...AXIS_FONT_PROPS}
>
{truncate(name, 20)}
</text>
</>
);
};

View File

@@ -0,0 +1,59 @@
import { api } from '@/trpc/client';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
import { ReportChartLoading } from '../common/loading';
import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportPieChart() {
const { isLazyLoading, report } = useReportChartContext();
const res = api.chart.chart.useQuery(report, {
keepPreviousData: true,
staleTime: 1000 * 60 * 1,
});
if (isLazyLoading || res.isLoading || res.isFetching) {
return <Loading />;
}
if (res.isError) {
return <Error />;
}
if (res.data.series.length === 0) {
return <Empty />;
}
return (
<AspectContainer>
<Chart data={res.data} />
</AspectContainer>
);
}
function Loading() {
return (
<AspectContainer>
<ReportChartLoading />
</AspectContainer>
);
}
function Error() {
return (
<AspectContainer>
<ReportChartError />
</AspectContainer>
);
}
function Empty() {
return (
<AspectContainer>
<ReportChartEmpty />
</AspectContainer>
);
}

View File

@@ -0,0 +1,43 @@
import { ReportChart } from '.';
import type { ReportChartProps } from './context';
type ChartRootShortcutProps = Omit<ReportChartProps, 'report'> & {
projectId: ReportChartProps['report']['projectId'];
range?: ReportChartProps['report']['range'];
previous?: ReportChartProps['report']['previous'];
chartType?: ReportChartProps['report']['chartType'];
interval?: ReportChartProps['report']['interval'];
events: ReportChartProps['report']['events'];
breakdowns?: ReportChartProps['report']['breakdowns'];
lineType?: ReportChartProps['report']['lineType'];
};
export const ReportChartShortcut = ({
projectId,
range = '7d',
previous = false,
chartType = 'linear',
interval = 'day',
events,
breakdowns,
lineType = 'monotone',
options,
}: ChartRootShortcutProps) => {
return (
<ReportChart
report={{
name: 'Shortcut',
projectId,
range,
breakdowns: breakdowns ?? [],
previous,
chartType,
interval,
events,
lineType,
metric: 'sum',
}}
options={options ?? {}}
/>
);
};