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

@@ -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}
</>
);
}

View File

@@ -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>;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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)!;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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}
/>
)}
</>
);
}

View File

@@ -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>
// );
}

View File

@@ -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>
);
}

View File

@@ -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}
/>
)}
</>
);
}

View File

@@ -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}
/>
)}
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
};

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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}</>;
}

View File

@@ -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;
}

View File

@@ -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}
/>
);
};

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}