refactor(dashboard): the chart component is now cleaned up and easier to extend
This commit is contained in:
@@ -1,96 +0,0 @@
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { cn } from '@/utils/cn';
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
TrendingDownIcon,
|
||||
TrendingUpIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Badge } from '../ui/badge';
|
||||
import { useChartContext } from './chart/ChartProvider';
|
||||
|
||||
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 { previous, previousIndicatorInverted } = useChartContext();
|
||||
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(
|
||||
'font-mono flex items-center gap-1 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '@/trpc/client';
|
||||
import debounce from 'lodash.debounce';
|
||||
import isEqual from 'lodash.isequal';
|
||||
|
||||
import type { IChartProps } from '@openpanel/validation';
|
||||
|
||||
import { ChartEmpty } from './ChartEmpty';
|
||||
import { useChartContext } from './ChartProvider';
|
||||
import { ReportAreaChart } from './ReportAreaChart';
|
||||
import { ReportBarChart } from './ReportBarChart';
|
||||
import { ReportHistogramChart } from './ReportHistogramChart';
|
||||
import { ReportLineChart } from './ReportLineChart';
|
||||
import { ReportMapChart } from './ReportMapChart';
|
||||
import { ReportMetricChart } from './ReportMetricChart';
|
||||
import { ReportPieChart } from './ReportPieChart';
|
||||
|
||||
export type ReportChartProps = IChartProps;
|
||||
|
||||
const pluckChartContext = (context: IChartProps) => ({
|
||||
chartType: context.chartType,
|
||||
interval: context.interval,
|
||||
breakdowns: context.breakdowns,
|
||||
range: context.range,
|
||||
previous: context.previous,
|
||||
formula: context.formula,
|
||||
metric: context.metric,
|
||||
projectId: context.projectId,
|
||||
startDate: context.startDate,
|
||||
endDate: context.endDate,
|
||||
limit: context.limit,
|
||||
offset: context.offset,
|
||||
events: context.events.map((event) => ({
|
||||
...event,
|
||||
filters: event.filters?.filter((filter) => filter.value.length > 0),
|
||||
})),
|
||||
});
|
||||
|
||||
// TODO: Quick hack to avoid re-fetching
|
||||
// Will refactor the entire chart component soon anyway...
|
||||
function useChartData() {
|
||||
const context = useChartContext();
|
||||
const [params, setParams] = useState(() => pluckChartContext(context));
|
||||
const debouncedSetParams = useMemo(() => debounce(setParams, 500), []);
|
||||
|
||||
useEffect(() => {
|
||||
const newParams = pluckChartContext(context);
|
||||
if (!isEqual(newParams, params)) {
|
||||
debouncedSetParams(newParams);
|
||||
}
|
||||
return () => {
|
||||
debouncedSetParams.cancel();
|
||||
};
|
||||
}, [context, params, debouncedSetParams]);
|
||||
|
||||
return api.chart.chart.useSuspenseQuery(params, {
|
||||
keepPreviousData: true,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
});
|
||||
}
|
||||
|
||||
export function Chart() {
|
||||
const { chartType } = useChartContext();
|
||||
const [data] = useChartData();
|
||||
|
||||
if (data.series.length === 0) {
|
||||
return <ChartEmpty />;
|
||||
}
|
||||
|
||||
if (chartType === 'map') {
|
||||
return <ReportMapChart data={data} />;
|
||||
}
|
||||
|
||||
if (chartType === 'histogram') {
|
||||
return <ReportHistogramChart data={data} />;
|
||||
}
|
||||
|
||||
if (chartType === 'bar') {
|
||||
return <ReportBarChart data={data} />;
|
||||
}
|
||||
|
||||
if (chartType === 'metric') {
|
||||
return <ReportMetricChart data={data} />;
|
||||
}
|
||||
|
||||
if (chartType === 'pie') {
|
||||
return <ReportPieChart data={data} />;
|
||||
}
|
||||
|
||||
if (chartType === 'linear') {
|
||||
return <ReportLineChart data={data} />;
|
||||
}
|
||||
|
||||
if (chartType === 'area') {
|
||||
return <ReportAreaChart data={data} />;
|
||||
}
|
||||
|
||||
return <p>Unknown chart type</p>;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
|
||||
import { useChartContext } from './ChartProvider';
|
||||
import { MetricCardEmpty } from './MetricCard';
|
||||
import { ResponsiveContainer } from './ResponsiveContainer';
|
||||
|
||||
export function ChartEmpty() {
|
||||
const { editMode, chartType } = useChartContext();
|
||||
|
||||
if (editMode) {
|
||||
return (
|
||||
<FullPageEmptyState title="No data">
|
||||
We could not find any data for selected events and filter.
|
||||
</FullPageEmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
if (chartType === 'metric') {
|
||||
return <MetricCardEmpty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer>
|
||||
<div className={'flex h-full w-full items-center justify-center'}>
|
||||
No data
|
||||
</div>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import { ResponsiveContainer } from './ResponsiveContainer';
|
||||
|
||||
interface ChartLoadingProps {
|
||||
className?: string;
|
||||
aspectRatio?: number;
|
||||
}
|
||||
export function ChartLoading({ className, aspectRatio }: ChartLoadingProps) {
|
||||
return (
|
||||
<ResponsiveContainer aspectRatio={aspectRatio}>
|
||||
<div
|
||||
className={cn(
|
||||
'h-full w-full animate-pulse rounded bg-def-200',
|
||||
className
|
||||
)}
|
||||
/>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
import type { IChartProps, IChartSerie } from '@openpanel/validation';
|
||||
|
||||
export interface IChartContextType extends IChartProps {
|
||||
hideXAxis?: boolean;
|
||||
hideYAxis?: boolean;
|
||||
aspectRatio?: number;
|
||||
editMode?: boolean;
|
||||
hideID?: boolean;
|
||||
onClick?: (item: IChartSerie) => void;
|
||||
renderSerieName?: (names: string[]) => React.ReactNode;
|
||||
renderSerieIcon?: (serie: IChartSerie) => React.ReactNode;
|
||||
dropdownMenuContent?: (serie: IChartSerie) => {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
onClick: () => void;
|
||||
}[];
|
||||
}
|
||||
|
||||
type IChartProviderProps = {
|
||||
children: React.ReactNode;
|
||||
} & IChartContextType;
|
||||
|
||||
const ChartContext = createContext<IChartContextType | null>(null);
|
||||
|
||||
export function ChartProvider({ children, ...props }: IChartProviderProps) {
|
||||
return (
|
||||
<ChartContext.Provider
|
||||
value={
|
||||
props.chartType === 'funnel' ? { ...props, previous: true } : props
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useChartContext() {
|
||||
return useContext(ChartContext)!;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useInViewport } from 'react-in-viewport';
|
||||
|
||||
import type { IChartRoot } from '.';
|
||||
import { ChartRoot } from '.';
|
||||
import { ChartLoading } from './ChartLoading';
|
||||
|
||||
export function LazyChart({
|
||||
className,
|
||||
...props
|
||||
}: IChartRoot & { className?: string }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const once = useRef(false);
|
||||
const { inViewport } = useInViewport(ref, undefined, {
|
||||
disconnectOnLeave: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (inViewport) {
|
||||
once.current = true;
|
||||
}
|
||||
}, [inViewport]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn('w-full', className)}>
|
||||
{once.current || inViewport ? (
|
||||
<ChartRoot {...props} editMode={false} />
|
||||
) : (
|
||||
<ChartLoading aspectRatio={props.aspectRatio} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
'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 '../PreviousDiffIndicator';
|
||||
import { useChartContext } from './ChartProvider';
|
||||
import { SerieName } from './SerieName';
|
||||
|
||||
interface MetricCardProps {
|
||||
serie: IChartData['series'][number];
|
||||
color?: string;
|
||||
metric: IChartMetric;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export function MetricCard({
|
||||
serie,
|
||||
color: _color,
|
||||
metric,
|
||||
unit,
|
||||
}: MetricCardProps) {
|
||||
const { previousIndicatorInverted, editMode } = useChartContext();
|
||||
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 h-[70px] overflow-hidden',
|
||||
editMode && 'card h-[100px] px-4 py-2'
|
||||
)}
|
||||
key={serie.id}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute -bottom-1 -left-1 -right-1 top-0 z-0 opacity-50 transition-opacity duration-300 group-hover:opacity-100',
|
||||
editMode && 'bottom-1'
|
||||
)}
|
||||
>
|
||||
<AutoSizer>
|
||||
{({ width, height }) => (
|
||||
<AreaChart
|
||||
width={width}
|
||||
height={height / 3}
|
||||
data={serie.data}
|
||||
style={{ marginTop: (height / 3) * 2 }}
|
||||
>
|
||||
<Area
|
||||
dataKey="count"
|
||||
type="monotone"
|
||||
fill={`transparent`}
|
||||
fillOpacity={1}
|
||||
stroke={graphColors}
|
||||
strokeWidth={2}
|
||||
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="font-mono truncate 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>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricCardEmpty() {
|
||||
return (
|
||||
<div className="card h-24 p-4">
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
No data
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricCardLoading() {
|
||||
return (
|
||||
<div className="flex h-[70px] flex-col justify-between">
|
||||
<div className="h-4 w-1/2 animate-pulse rounded bg-def-200"></div>
|
||||
<div className="h-8 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>
|
||||
);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
|
||||
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
|
||||
import type { IChartData } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { getYAxisWidth } from './chart-utils';
|
||||
import { useChartContext } from './ChartProvider';
|
||||
import { ReportChartTooltip } from './ReportChartTooltip';
|
||||
import { ReportTable } from './ReportTable';
|
||||
import { ResponsiveContainer } from './ResponsiveContainer';
|
||||
|
||||
interface ReportAreaChartProps {
|
||||
data: IChartData;
|
||||
}
|
||||
|
||||
export function ReportAreaChart({ data }: ReportAreaChartProps) {
|
||||
const { editMode, lineType, interval } = useChartContext();
|
||||
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
const rechartData = useRechartDataModel(series);
|
||||
const number = useNumber();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn(editMode && 'card p-4')}>
|
||||
<ResponsiveContainer>
|
||||
{({ width, height }) => (
|
||||
<AreaChart width={width} height={height} data={rechartData}>
|
||||
<Tooltip content={<ReportChartTooltip />} />
|
||||
<XAxis
|
||||
axisLine={false}
|
||||
fontSize={12}
|
||||
dataKey="date"
|
||||
tickFormatter={(m: string) => formatDate(m)}
|
||||
tickLine={false}
|
||||
allowDuplicatedCategory={false}
|
||||
/>
|
||||
<YAxis
|
||||
width={getYAxisWidth(data.metrics.max)}
|
||||
fontSize={12}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
allowDecimals={false}
|
||||
tickFormatter={number.short}
|
||||
/>
|
||||
|
||||
{series.map((serie) => {
|
||||
const color = getChartColor(serie.index);
|
||||
return (
|
||||
<React.Fragment key={serie.id}>
|
||||
<defs>
|
||||
<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>
|
||||
</defs>
|
||||
<Area
|
||||
key={serie.id}
|
||||
type={lineType}
|
||||
isAnimationActive={false}
|
||||
strokeWidth={2}
|
||||
dataKey={`${serie.id}:count`}
|
||||
stroke={color}
|
||||
fill={`url(#color${color})`}
|
||||
stackId={'1'}
|
||||
fillOpacity={1}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
className="stroke-def-200"
|
||||
/>
|
||||
</AreaChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{editMode && (
|
||||
<ReportTable
|
||||
data={data}
|
||||
visibleSeries={series}
|
||||
setVisibleSeries={setVisibleSeries}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
'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 '../PreviousDiffIndicator';
|
||||
import { useChartContext } from './ChartProvider';
|
||||
import { SerieIcon } from './SerieIcon';
|
||||
import { SerieName } from './SerieName';
|
||||
|
||||
interface ReportBarChartProps {
|
||||
data: IChartData;
|
||||
}
|
||||
|
||||
export function ReportBarChart({ data }: ReportBarChartProps) {
|
||||
const { editMode, metric, onClick, limit, dropdownMenuContent } =
|
||||
useChartContext();
|
||||
const number = useNumber();
|
||||
const series = useMemo(
|
||||
() => (editMode ? data.series : data.series.slice(0, limit || 10)),
|
||||
[data, editMode, limit]
|
||||
);
|
||||
const maxCount = Math.max(...series.map((serie) => serie.metrics[metric]));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col text-sm',
|
||||
editMode ? '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="font-mono flex flex-shrink-0 items-center justify-end gap-4">
|
||||
<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>
|
||||
);
|
||||
|
||||
// return (
|
||||
// <Table
|
||||
// overflow={editMode}
|
||||
// className={cn('table-fixed', editMode ? '' : 'mini')}
|
||||
// >
|
||||
// <TableHeader>
|
||||
// {table.getHeaderGroups().map((headerGroup) => (
|
||||
// <TableRow key={headerGroup.id}>
|
||||
// {headerGroup.headers.map((header) => (
|
||||
// <TableHead
|
||||
// key={header.id}
|
||||
// {...{
|
||||
// colSpan: header.colSpan,
|
||||
// }}
|
||||
// >
|
||||
// <div
|
||||
// {...{
|
||||
// className: cn(
|
||||
// 'flex items-center gap-2',
|
||||
// header.column.getCanSort() && 'cursor-pointer select-none'
|
||||
// ),
|
||||
// onClick: header.column.getToggleSortingHandler(),
|
||||
// }}
|
||||
// >
|
||||
// {flexRender(
|
||||
// header.column.columnDef.header,
|
||||
// header.getContext()
|
||||
// )}
|
||||
// {{
|
||||
// asc: <ChevronUp className="ml-auto" size={14} />,
|
||||
// desc: <ChevronDown className="ml-auto" size={14} />,
|
||||
// }[header.column.getIsSorted() as string] ?? null}
|
||||
// </div>
|
||||
// </TableHead>
|
||||
// ))}
|
||||
// </TableRow>
|
||||
// ))}
|
||||
// </TableHeader>
|
||||
// <TableBody>
|
||||
// {table.getRowModel().rows.map((row) => (
|
||||
// <TableRow
|
||||
// key={row.id}
|
||||
// {...(onClick
|
||||
// ? {
|
||||
// onClick() {
|
||||
// onClick(row.original);
|
||||
// },
|
||||
// className: 'cursor-pointer',
|
||||
// }
|
||||
// : {})}
|
||||
// >
|
||||
// {row.getVisibleCells().map((cell) => (
|
||||
// <TableCell key={cell.id}>
|
||||
// {flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
// </TableCell>
|
||||
// ))}
|
||||
// </TableRow>
|
||||
// ))}
|
||||
// </TableBody>
|
||||
// </Table>
|
||||
// );
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
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 { PreviousDiffIndicator } from '../PreviousDiffIndicator';
|
||||
import { useChartContext } from './ChartProvider';
|
||||
import { SerieIcon } from './SerieIcon';
|
||||
import { SerieName } from './SerieName';
|
||||
|
||||
type ReportLineChartTooltipProps = IToolTipProps<{
|
||||
value: number;
|
||||
name: string;
|
||||
dataKey: string;
|
||||
payload: Record<string, unknown>;
|
||||
}>;
|
||||
|
||||
export function ReportChartTooltip({
|
||||
active,
|
||||
payload,
|
||||
}: ReportLineChartTooltipProps) {
|
||||
const { unit, interval } = useChartContext();
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
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, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
import { getYAxisWidth } from './chart-utils';
|
||||
import { useChartContext } from './ChartProvider';
|
||||
import { ReportChartTooltip } from './ReportChartTooltip';
|
||||
import { ReportTable } from './ReportTable';
|
||||
import { ResponsiveContainer } from './ResponsiveContainer';
|
||||
|
||||
interface ReportHistogramChartProps {
|
||||
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 ReportHistogramChart({ data }: ReportHistogramChartProps) {
|
||||
const { editMode, previous, interval, aspectRatio } = useChartContext();
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||
const rechartData = useRechartDataModel(series);
|
||||
const number = useNumber();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn('w-full', editMode && 'card p-4')}>
|
||||
<ResponsiveContainer aspectRatio={aspectRatio}>
|
||||
{({ width, height }) => (
|
||||
<BarChart
|
||||
width={width}
|
||||
height={height}
|
||||
data={rechartData}
|
||||
barCategoryGap={10}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
className="stroke-def-200"
|
||||
/>
|
||||
<Tooltip content={<ReportChartTooltip />} cursor={<BarHover />} />
|
||||
<XAxis
|
||||
fontSize={12}
|
||||
dataKey="date"
|
||||
tickFormatter={formatDate}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
fontSize={12}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={getYAxisWidth(data.metrics.max)}
|
||||
allowDecimals={false}
|
||||
domain={[0, data.metrics.max]}
|
||||
tickFormatter={number.short}
|
||||
/>
|
||||
{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>
|
||||
{editMode && (
|
||||
<ReportTable
|
||||
data={data}
|
||||
visibleSeries={series}
|
||||
setVisibleSeries={setVisibleSeries}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
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,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { getYAxisWidth } from './chart-utils';
|
||||
import { useChartContext } from './ChartProvider';
|
||||
import { ReportChartTooltip } from './ReportChartTooltip';
|
||||
import { ReportTable } from './ReportTable';
|
||||
import { ResponsiveContainer } from './ResponsiveContainer';
|
||||
import { SerieIcon } from './SerieIcon';
|
||||
import { SerieName } from './SerieName';
|
||||
|
||||
interface ReportLineChartProps {
|
||||
data: IChartData;
|
||||
}
|
||||
|
||||
export function ReportLineChart({ data }: ReportLineChartProps) {
|
||||
const {
|
||||
editMode,
|
||||
previous,
|
||||
interval,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
lineType,
|
||||
aspectRatio,
|
||||
hideXAxis,
|
||||
hideYAxis,
|
||||
} = useChartContext();
|
||||
const dataLength = data.series[0]?.data?.length || 0;
|
||||
const references = api.reference.getChartReferences.useQuery(
|
||||
{
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
},
|
||||
{
|
||||
staleTime: 1000 * 60 * 10,
|
||||
}
|
||||
);
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||
const rechartData = useRechartDataModel(series);
|
||||
const number = useNumber();
|
||||
|
||||
// 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;
|
||||
return (
|
||||
<>
|
||||
<div className={cn('w-full', editMode && 'card p-4')}>
|
||||
<ResponsiveContainer aspectRatio={aspectRatio}>
|
||||
{({ width, height }) => (
|
||||
<ComposedChart width={width} height={height} 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
|
||||
width={hideYAxis ? 0 : getYAxisWidth(data.metrics.max)}
|
||||
fontSize={12}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
allowDecimals={false}
|
||||
tickFormatter={number.short}
|
||||
/>
|
||||
{series.length > 1 && (
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '10px' }}
|
||||
content={<CustomLegend />}
|
||||
/>
|
||||
)}
|
||||
<Tooltip content={<ReportChartTooltip />} />
|
||||
<XAxis
|
||||
height={hideXAxis ? 0 : undefined}
|
||||
axisLine={false}
|
||||
fontSize={12}
|
||||
dataKey="timestamp"
|
||||
scale="utc"
|
||||
domain={['dataMin', 'dataMax']}
|
||||
tickFormatter={(m: string) => formatDate(new Date(m))}
|
||||
type="number"
|
||||
tickLine={false}
|
||||
/>
|
||||
{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>
|
||||
{editMode && (
|
||||
<ReportTable
|
||||
data={data}
|
||||
visibleSeries={series}
|
||||
setVisibleSeries={setVisibleSeries}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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 { useChartContext } from './ChartProvider';
|
||||
|
||||
interface ReportMapChartProps {
|
||||
data: IChartData;
|
||||
}
|
||||
|
||||
export function ReportMapChart({ data }: ReportMapChartProps) {
|
||||
const { metric, unit } = useChartContext();
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
|
||||
import type { IChartData } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import { useChartContext } from './ChartProvider';
|
||||
import { MetricCard } from './MetricCard';
|
||||
|
||||
interface ReportMetricChartProps {
|
||||
data: IChartData;
|
||||
}
|
||||
|
||||
export function ReportMetricChart({ data }: ReportMetricChartProps) {
|
||||
const { editMode, metric, unit } = useChartContext();
|
||||
const { series } = useVisibleSeries(data, editMode ? 20 : 4);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-1 gap-4',
|
||||
editMode && 'md:grid-cols-2 lg:grid-cols-3'
|
||||
)}
|
||||
>
|
||||
{series.map((serie) => {
|
||||
return (
|
||||
<MetricCard
|
||||
key={serie.id}
|
||||
serie={serie}
|
||||
metric={metric}
|
||||
unit={unit}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { AutoSizer } from '@/components/react-virtualized-auto-sizer';
|
||||
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, Tooltip } from 'recharts';
|
||||
|
||||
import { useChartContext } from './ChartProvider';
|
||||
import { ReportChartTooltip } from './ReportChartTooltip';
|
||||
import { ReportTable } from './ReportTable';
|
||||
|
||||
interface ReportPieChartProps {
|
||||
data: IChartData;
|
||||
}
|
||||
|
||||
export function ReportPieChart({ data }: ReportPieChartProps) {
|
||||
const { editMode } = useChartContext();
|
||||
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('max-sm:-mx-3', editMode && 'card p-4')}>
|
||||
<AutoSizer disableHeight>
|
||||
{({ width }) => {
|
||||
const height = Math.min(Math.max(width * 0.5625, 250), 400);
|
||||
return (
|
||||
<PieChart width={width} height={height}>
|
||||
<Tooltip content={<ReportChartTooltip />} />
|
||||
<Pie
|
||||
dataKey={'count'}
|
||||
data={pieData}
|
||||
innerRadius={height / 4}
|
||||
outerRadius={height / 2.5}
|
||||
isAnimationActive={false}
|
||||
label={renderLabel}
|
||||
>
|
||||
{pieData.map((item) => {
|
||||
return (
|
||||
<Cell
|
||||
key={item.id}
|
||||
strokeWidth={2}
|
||||
stroke={item.color}
|
||||
fill={item.color}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
);
|
||||
}}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
{editMode && (
|
||||
<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"
|
||||
fontSize={12}
|
||||
fontWeight={700}
|
||||
pointerEvents={'none'}
|
||||
>
|
||||
{percent}%
|
||||
</text>
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fill={fill}
|
||||
textAnchor={x > cx ? 'start' : 'end'}
|
||||
dominantBaseline="central"
|
||||
fontSize={12}
|
||||
>
|
||||
{truncate(name, 20)}
|
||||
</text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,191 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { Pagination, usePagination } from '@/components/pagination';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
Tooltiper,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
||||
import { useMappings } from '@/hooks/useMappings';
|
||||
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 '../PreviousDiffIndicator';
|
||||
import { SerieName } from './SerieName';
|
||||
|
||||
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 (
|
||||
<>
|
||||
<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="flex flex-col-reverse gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge>Total: {number.format(data.metrics.sum)}</Badge>
|
||||
<Badge>Average: {number.format(data.metrics.average)}</Badge>
|
||||
<Badge>Min: {number.format(data.metrics.min)}</Badge>
|
||||
<Badge>Max: {number.format(data.metrics.max)}</Badge>
|
||||
</div>
|
||||
<Pagination
|
||||
cursor={page}
|
||||
setCursor={setPage}
|
||||
take={ROWS_LIMIT}
|
||||
count={data.series.length}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
|
||||
import { DEFAULT_ASPECT_RATIO } from '@openpanel/constants';
|
||||
|
||||
interface ResponsiveContainerProps {
|
||||
aspectRatio?: number;
|
||||
children:
|
||||
| ((props: { width: number; height: number }) => React.ReactNode)
|
||||
| React.ReactNode;
|
||||
}
|
||||
|
||||
export function ResponsiveContainer({
|
||||
children,
|
||||
aspectRatio = 0.5625,
|
||||
}: ResponsiveContainerProps) {
|
||||
const maxHeight = 300;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full"
|
||||
style={{
|
||||
aspectRatio: 1 / (aspectRatio || DEFAULT_ASPECT_RATIO),
|
||||
maxHeight,
|
||||
}}
|
||||
>
|
||||
{typeof children === 'function' ? (
|
||||
<AutoSizer disableHeight>
|
||||
{({ width }) =>
|
||||
children({
|
||||
width,
|
||||
height: Math.min(
|
||||
maxHeight,
|
||||
width * aspectRatio || DEFAULT_ASPECT_RATIO
|
||||
),
|
||||
})
|
||||
}
|
||||
</AutoSizer>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
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;
|
||||
@@ -1,99 +0,0 @@
|
||||
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 './SerieIcon.flags';
|
||||
import iconsWithUrls from './SerieIcon.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;
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,40 +0,0 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
|
||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||
|
||||
import { useChartContext } from './ChartProvider';
|
||||
|
||||
interface SerieNameProps {
|
||||
name: string | string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SerieName({ name, className }: SerieNameProps) {
|
||||
const chart = useChartContext();
|
||||
if (Array.isArray(name)) {
|
||||
if (chart.renderSerieName) {
|
||||
return chart.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 (chart.renderSerieName) {
|
||||
return chart.renderSerieName([name]);
|
||||
}
|
||||
|
||||
return <>{name}</>;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
const formatter = new Intl.NumberFormat('en', {
|
||||
notation: 'compact',
|
||||
});
|
||||
|
||||
export function getYAxisWidth(value: number) {
|
||||
if (!isFinite(value)) {
|
||||
return 7.8 + 7.8;
|
||||
}
|
||||
|
||||
return formatter.format(value).toString().length * 7.8 + 7.8;
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
import * as Portal from '@radix-ui/react-portal';
|
||||
|
||||
import type { IChartProps } from '@openpanel/validation';
|
||||
|
||||
import { Funnel } from '../funnel';
|
||||
import { Chart } from './Chart';
|
||||
import { ChartLoading } from './ChartLoading';
|
||||
import type { IChartContextType } from './ChartProvider';
|
||||
import { ChartProvider } from './ChartProvider';
|
||||
import { MetricCardLoading } from './MetricCard';
|
||||
|
||||
export type IChartRoot = IChartContextType;
|
||||
|
||||
export function ChartRoot(props: IChartContextType) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return props.chartType === 'metric' ? (
|
||||
<MetricCardLoading />
|
||||
) : (
|
||||
<ChartLoading aspectRatio={props.aspectRatio} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
props.chartType === 'metric' ? (
|
||||
<MetricCardLoading />
|
||||
) : (
|
||||
<ChartLoading aspectRatio={props.aspectRatio} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<ChartProvider {...props}>
|
||||
{props.chartType === 'funnel' ? <Funnel /> : <Chart />}
|
||||
</ChartProvider>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChartRootShortcutProps {
|
||||
projectId: IChartProps['projectId'];
|
||||
range?: IChartProps['range'];
|
||||
previous?: IChartProps['previous'];
|
||||
chartType?: IChartProps['chartType'];
|
||||
interval?: IChartProps['interval'];
|
||||
events: IChartProps['events'];
|
||||
breakdowns?: IChartProps['breakdowns'];
|
||||
lineType?: IChartProps['lineType'];
|
||||
hideXAxis?: boolean;
|
||||
aspectRatio?: number;
|
||||
}
|
||||
|
||||
export const ChartRootShortcut = ({
|
||||
hideXAxis,
|
||||
projectId,
|
||||
range = '7d',
|
||||
previous = false,
|
||||
chartType = 'linear',
|
||||
interval = 'day',
|
||||
events,
|
||||
breakdowns,
|
||||
aspectRatio,
|
||||
lineType = 'monotone',
|
||||
}: ChartRootShortcutProps) => {
|
||||
return (
|
||||
<ChartRoot
|
||||
projectId={projectId}
|
||||
range={range}
|
||||
breakdowns={breakdowns ?? []}
|
||||
previous={previous}
|
||||
chartType={chartType}
|
||||
interval={interval}
|
||||
name="Random"
|
||||
lineType={lineType}
|
||||
metric="sum"
|
||||
events={events}
|
||||
aspectRatio={aspectRatio}
|
||||
hideXAxis={hideXAxis}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,176 +0,0 @@
|
||||
// @ts-nocheck
|
||||
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
} from '@/components/ui/carousel';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { round } from '@/utils/math';
|
||||
import { ArrowRightIcon } from 'lucide-react';
|
||||
|
||||
import { useChartContext } from '../chart/ChartProvider';
|
||||
|
||||
function FunnelChart({ from, to }: { from: number; to: number }) {
|
||||
const fromY = 100 - from;
|
||||
const toY = 100 - to;
|
||||
const steps = [
|
||||
`M0,${fromY}`,
|
||||
'L0,100',
|
||||
'L100,100',
|
||||
`L100,${toY}`,
|
||||
`L0,${fromY}`,
|
||||
];
|
||||
return (
|
||||
<svg viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="blue"
|
||||
x1="50"
|
||||
y1="100"
|
||||
x2="50"
|
||||
y2="0"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
{/* bottom */}
|
||||
<stop offset="0%" stop-color="#2564eb" />
|
||||
{/* top */}
|
||||
<stop offset="100%" stop-color="#2564eb" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="red"
|
||||
x1="50"
|
||||
y1="100"
|
||||
x2="50"
|
||||
y2="0"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
{/* bottom */}
|
||||
<stop offset="0%" stop-color="#f87171" />
|
||||
{/* top */}
|
||||
<stop offset="100%" stop-color="#fca5a5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect
|
||||
x="0"
|
||||
y={fromY}
|
||||
width="100"
|
||||
height="100"
|
||||
fill="url(#red)"
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
<path d={steps.join(' ')} fill="url(#blue)" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function getDropoffColor(value: number) {
|
||||
if (value > 80) {
|
||||
return 'text-red-600';
|
||||
}
|
||||
if (value > 50) {
|
||||
return 'text-orange-600';
|
||||
}
|
||||
if (value > 30) {
|
||||
return 'text-yellow-600';
|
||||
}
|
||||
return 'text-green-600';
|
||||
}
|
||||
|
||||
export function FunnelSteps({
|
||||
current: { steps, totalSessions },
|
||||
}: RouterOutputs['chart']['funnel']) {
|
||||
const { editMode } = useChartContext();
|
||||
return (
|
||||
<Carousel className="w-full" opts={{ loop: false, dragFree: true }}>
|
||||
<CarouselContent>
|
||||
<CarouselItem className={'flex-[0_0_0] pl-3'} />
|
||||
{steps.map((step, index, list) => {
|
||||
const finalStep = index === list.length - 1;
|
||||
return (
|
||||
<CarouselItem
|
||||
className={cn(
|
||||
'max-w-full flex-[0_0_250px] p-0 px-1',
|
||||
editMode && 'flex-[0_0_320px]'
|
||||
)}
|
||||
key={step.event.id}
|
||||
>
|
||||
<div className="card divide-y divide-border bg-card">
|
||||
<div className="p-4">
|
||||
<p className="text-muted-foreground">Step {index + 1}</p>
|
||||
<h3 className="font-bold">
|
||||
{step.event.displayName || step.event.name}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="relative aspect-square">
|
||||
<FunnelChart from={step.prevPercent} to={step.percent} />
|
||||
<div className="absolute left-0 right-0 top-0 flex flex-col bg-card/40 p-4">
|
||||
<div className="font-medium uppercase text-muted-foreground">
|
||||
Sessions
|
||||
</div>
|
||||
<div className="flex items-center text-3xl font-bold uppercase">
|
||||
<span className="text-muted-foreground">
|
||||
{step.before}
|
||||
</span>
|
||||
<ArrowRightIcon size={16} className="mx-2" />
|
||||
<span>{step.current}</span>
|
||||
</div>
|
||||
{index !== 0 && (
|
||||
<>
|
||||
<div className="text-muted-foreground">
|
||||
{step.current} of {totalSessions} (
|
||||
{round(step.percent, 1)}%)
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{finalStep ? (
|
||||
<div className={cn('flex flex-col items-center p-4')}>
|
||||
<div className="text-sm font-medium uppercase">
|
||||
Conversion
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'text-3xl font-bold uppercase',
|
||||
getDropoffColor(step.dropoff.percent)
|
||||
)}
|
||||
>
|
||||
{round(step.percent, 1)}%
|
||||
</div>
|
||||
<div className="mt-0 font-medium uppercase text-muted-foreground">
|
||||
Converted {step.current} of {totalSessions} sessions
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn('flex flex-col items-center p-4')}>
|
||||
<div className="text-sm font-medium uppercase">Dropoff</div>
|
||||
<div
|
||||
className={cn(
|
||||
'text-3xl font-bold uppercase',
|
||||
getDropoffColor(step.dropoff.percent)
|
||||
)}
|
||||
>
|
||||
{round(step.dropoff.percent, 1)}%
|
||||
</div>
|
||||
<div className="mt-0 font-medium uppercase text-muted-foreground">
|
||||
Lost {step.dropoff.count} sessions
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CarouselItem>
|
||||
);
|
||||
})}
|
||||
<CarouselItem className={'flex-[0_0_0px] pl-3'} />
|
||||
</CarouselContent>
|
||||
<CarouselPrevious />
|
||||
<CarouselNext />
|
||||
</Carousel>
|
||||
);
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
'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 type { IChartInput } from '@openpanel/validation';
|
||||
|
||||
import { useChartContext } from '../chart/ChartProvider';
|
||||
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
|
||||
|
||||
const findMostDropoffs = (
|
||||
steps: RouterOutputs['chart']['funnel']['current']['steps']
|
||||
) => {
|
||||
return steps.reduce((acc, step) => {
|
||||
if (step.dropoffCount > acc.dropoffCount) {
|
||||
return step;
|
||||
}
|
||||
return acc;
|
||||
});
|
||||
};
|
||||
|
||||
function InsightCard({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col rounded-lg border border-border p-4 py-3">
|
||||
<span className="">{title}</span>
|
||||
<div className="whitespace-nowrap text-lg">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = RouterOutputs['chart']['funnel'] & {
|
||||
input: IChartInput;
|
||||
};
|
||||
|
||||
export function FunnelSteps({
|
||||
current: { steps, totalSessions },
|
||||
previous,
|
||||
input,
|
||||
}: Props) {
|
||||
const { editMode } = useChartContext();
|
||||
const mostDropoffs = findMostDropoffs(steps);
|
||||
const lastStep = last(steps)!;
|
||||
const prevLastStep = last(previous.steps)!;
|
||||
const hasIncreased = lastStep.percent > prevLastStep.percent;
|
||||
const withWidget = (children: React.ReactNode) => {
|
||||
if (editMode) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Widget>
|
||||
<WidgetBody>{children}</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
return withWidget(
|
||||
<div className="flex flex-col gap-4 @container">
|
||||
<div
|
||||
className={cn('border border-border', !editMode && '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 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">
|
||||
<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 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">
|
||||
<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 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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { api } from '@/trpc/client';
|
||||
|
||||
import type { IChartInput } from '@openpanel/validation';
|
||||
|
||||
import { ChartEmpty } from '../chart/ChartEmpty';
|
||||
import { useChartContext } from '../chart/ChartProvider';
|
||||
import { FunnelSteps } from './Funnel';
|
||||
|
||||
export function Funnel() {
|
||||
const { events, range, projectId } = useChartContext();
|
||||
|
||||
const input: IChartInput = {
|
||||
events,
|
||||
range,
|
||||
projectId,
|
||||
interval: 'day',
|
||||
chartType: 'funnel',
|
||||
breakdowns: [],
|
||||
previous: false,
|
||||
metric: 'sum',
|
||||
};
|
||||
const [data] = api.chart.funnel.useSuspenseQuery(input, {
|
||||
keepPreviousData: true,
|
||||
});
|
||||
|
||||
if (data.current.steps.length === 0) {
|
||||
return <ChartEmpty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="-m-4">
|
||||
<FunnelSteps {...data} input={input} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user