refactor(dashboard): the chart component is now cleaned up and easier to extend
This commit is contained in:
@@ -1,9 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { StickyBelowHeader } from '@/app/(app)/[organizationSlug]/[projectId]/layout-sticky-below-header';
|
|
||||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { LazyChart } from '@/components/report/chart/LazyChart';
|
import { ReportChart } from '@/components/report-chart';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -133,17 +132,22 @@ export function ListReports({ reports, dashboard }: ListReportsProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<div className={cn('p-4')}>
|
<div
|
||||||
<LazyChart
|
className={cn('p-4', report.chartType === 'metric' && 'p-0')}
|
||||||
|
>
|
||||||
|
<ReportChart
|
||||||
{...report}
|
{...report}
|
||||||
range={range ?? report.range}
|
report={{
|
||||||
startDate={startDate}
|
...report,
|
||||||
endDate={endDate}
|
range: range ?? report.range,
|
||||||
interval={
|
startDate: startDate ?? report.startDate,
|
||||||
|
endDate: endDate ?? report.endDate,
|
||||||
|
interval:
|
||||||
getDefaultIntervalByDates(startDate, endDate) ||
|
getDefaultIntervalByDates(startDate, endDate) ||
|
||||||
(range ? getDefaultIntervalByRange(range) : report.interval)
|
(range
|
||||||
}
|
? getDefaultIntervalByRange(range)
|
||||||
editMode={false}
|
: report.interval),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
|
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
|
||||||
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
|
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
|
||||||
import { ChartRootShortcut } from '@/components/report/chart';
|
import { ReportChartShortcut } from '@/components/report-chart/shortcut';
|
||||||
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
|
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
|
||||||
import {
|
import {
|
||||||
useEventQueryFilters,
|
useEventQueryFilters,
|
||||||
@@ -44,7 +44,7 @@ function Charts({ projectId }: Props) {
|
|||||||
<span className="title">Events per day</span>
|
<span className="title">Events per day</span>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
<ChartRootShortcut
|
<ReportChartShortcut
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
range="30d"
|
range="30d"
|
||||||
chartType="histogram"
|
chartType="histogram"
|
||||||
@@ -67,7 +67,7 @@ function Charts({ projectId }: Props) {
|
|||||||
<span className="title">Event distribution</span>
|
<span className="title">Event distribution</span>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
<ChartRootShortcut
|
<ReportChartShortcut
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
range="30d"
|
range="30d"
|
||||||
chartType="pie"
|
chartType="pie"
|
||||||
@@ -104,7 +104,7 @@ function Charts({ projectId }: Props) {
|
|||||||
<span className="title">Event distribution</span>
|
<span className="title">Event distribution</span>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
<ChartRootShortcut
|
<ReportChartShortcut
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
range="30d"
|
range="30d"
|
||||||
chartType="bar"
|
chartType="bar"
|
||||||
@@ -141,7 +141,7 @@ function Charts({ projectId }: Props) {
|
|||||||
<span className="title">Event distribution</span>
|
<span className="title">Event distribution</span>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
<ChartRootShortcut
|
<ReportChartShortcut
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
range="30d"
|
range="30d"
|
||||||
chartType="linear"
|
chartType="linear"
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ export default function LayoutContent({
|
|||||||
const segments = useSelectedLayoutSegments();
|
const segments = useSelectedLayoutSegments();
|
||||||
|
|
||||||
if (segments[0] && NOT_MIGRATED_PAGES.includes(segments[0])) {
|
if (segments[0] && NOT_MIGRATED_PAGES.includes(segments[0])) {
|
||||||
return <div className="transition-all lg:pl-72">{children}</div>;
|
return <div className="pb-20 transition-all lg:pl-72">{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className="transition-all max-lg:mt-12 lg:pl-72">{children}</div>;
|
return (
|
||||||
|
<div className="pb-20 transition-all max-lg:mt-12 lg:pl-72">{children}</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { LazyChart } from '@/components/report/chart/LazyChart';
|
import { ReportChart } from '@/components/report-chart';
|
||||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import isEqual from 'lodash.isequal';
|
import isEqual from 'lodash.isequal';
|
||||||
@@ -71,21 +71,25 @@ export const PagesTable = memo(
|
|||||||
index === data.length - 1 && 'rounded-br-md'
|
index === data.length - 1 && 'rounded-br-md'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<LazyChart
|
<ReportChart
|
||||||
hideYAxis
|
options={{
|
||||||
hideXAxis
|
hideID: true,
|
||||||
className="w-full"
|
hideXAxis: true,
|
||||||
lineType="linear"
|
hideYAxis: true,
|
||||||
breakdowns={[]}
|
aspectRatio: 0.15,
|
||||||
name="screen_view"
|
}}
|
||||||
metric="sum"
|
report={{
|
||||||
range="30d"
|
lineType: 'linear',
|
||||||
interval="day"
|
breakdowns: [],
|
||||||
previous
|
name: 'screen_view',
|
||||||
aspectRatio={0.15}
|
metric: 'sum',
|
||||||
chartType="linear"
|
range: '30d',
|
||||||
projectId={item.project_id}
|
interval: 'day',
|
||||||
events={[
|
previous: true,
|
||||||
|
|
||||||
|
chartType: 'linear',
|
||||||
|
projectId: item.project_id,
|
||||||
|
events: [
|
||||||
{
|
{
|
||||||
id: 'A',
|
id: 'A',
|
||||||
name: 'screen_view',
|
name: 'screen_view',
|
||||||
@@ -105,7 +109,8 @@ export const PagesTable = memo(
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]}
|
],
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { ChartRoot } from '@/components/report/chart';
|
import { ReportChart } from '@/components/report-chart';
|
||||||
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
|
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
|
||||||
|
|
||||||
import type { IChartProps } from '@openpanel/validation';
|
import type { IChartProps } from '@openpanel/validation';
|
||||||
@@ -85,7 +85,7 @@ const ProfileCharts = ({ profileId, projectId }: Props) => {
|
|||||||
<span className="title">Page views</span>
|
<span className="title">Page views</span>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
<ChartRoot {...pageViewsChart} />
|
<ReportChart report={pageViewsChart} />
|
||||||
</WidgetBody>
|
</WidgetBody>
|
||||||
</Widget>
|
</Widget>
|
||||||
<Widget className="col-span-6 md:col-span-3">
|
<Widget className="col-span-6 md:col-span-3">
|
||||||
@@ -93,7 +93,7 @@ const ProfileCharts = ({ profileId, projectId }: Props) => {
|
|||||||
<span className="title">Events per day</span>
|
<span className="title">Events per day</span>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
<ChartRoot {...eventsChart} />
|
<ReportChart report={eventsChart} />
|
||||||
</WidgetBody>
|
</WidgetBody>
|
||||||
</Widget>
|
</Widget>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function Card({ title, value }: { title: string; value: string }) {
|
|||||||
return (
|
return (
|
||||||
<div className="col gap-2 p-4 ring-[0.5px] ring-border">
|
<div className="col gap-2 p-4 ring-[0.5px] ring-border">
|
||||||
<div className="text-muted-foreground">{title}</div>
|
<div className="text-muted-foreground">{title}</div>
|
||||||
<div className="font-mono truncate text-2xl font-bold">{value}</div>
|
<div className="truncate font-mono text-2xl font-bold">{value}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -27,7 +27,7 @@ function Info({ title, value }: { title: string; value: string }) {
|
|||||||
return (
|
return (
|
||||||
<div className="col gap-2">
|
<div className="col gap-2">
|
||||||
<div className="capitalize text-muted-foreground">{title}</div>
|
<div className="capitalize text-muted-foreground">{title}</div>
|
||||||
<div className="font-mono truncate">{value || '-'}</div>
|
<div className="truncate font-mono">{value || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ import {
|
|||||||
FullscreenClose,
|
FullscreenClose,
|
||||||
FullscreenOpen,
|
FullscreenOpen,
|
||||||
} from '@/components/fullscreen-toggle';
|
} from '@/components/fullscreen-toggle';
|
||||||
import { LazyChart } from '@/components/report/chart/LazyChart';
|
import { ReportChart } from '@/components/report-chart';
|
||||||
|
|
||||||
import PageLayout from '../page-layout';
|
|
||||||
import RealtimeMap from './map';
|
import RealtimeMap from './map';
|
||||||
import RealtimeLiveEventsServer from './realtime-live-events';
|
import RealtimeLiveEventsServer from './realtime-live-events';
|
||||||
import { RealtimeLiveHistogram } from './realtime-live-histogram';
|
import { RealtimeLiveHistogram } from './realtime-live-histogram';
|
||||||
@@ -42,9 +41,11 @@ export default function Page({ params: { projectId } }: Props) {
|
|||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="font-bold">Pages</div>
|
<div className="font-bold">Pages</div>
|
||||||
</div>
|
</div>
|
||||||
<LazyChart
|
<ReportChart
|
||||||
hideID
|
options={{
|
||||||
{...{
|
hideID: true,
|
||||||
|
}}
|
||||||
|
report={{
|
||||||
projectId,
|
projectId,
|
||||||
events: [
|
events: [
|
||||||
{
|
{
|
||||||
@@ -74,9 +75,11 @@ export default function Page({ params: { projectId } }: Props) {
|
|||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="font-bold">Cities</div>
|
<div className="font-bold">Cities</div>
|
||||||
</div>
|
</div>
|
||||||
<LazyChart
|
<ReportChart
|
||||||
hideID
|
options={{
|
||||||
{...{
|
hideID: true,
|
||||||
|
}}
|
||||||
|
report={{
|
||||||
projectId,
|
projectId,
|
||||||
events: [
|
events: [
|
||||||
{
|
{
|
||||||
@@ -106,9 +109,11 @@ export default function Page({ params: { projectId } }: Props) {
|
|||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="font-bold">Referrers</div>
|
<div className="font-bold">Referrers</div>
|
||||||
</div>
|
</div>
|
||||||
<LazyChart
|
<ReportChart
|
||||||
hideID
|
options={{
|
||||||
{...{
|
hideID: true,
|
||||||
|
}}
|
||||||
|
report={{
|
||||||
projectId,
|
projectId,
|
||||||
events: [
|
events: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { StickyBelowHeader } from '@/app/(app)/[organizationSlug]/[projectId]/layout-sticky-below-header';
|
import { StickyBelowHeader } from '@/app/(app)/[organizationSlug]/[projectId]/layout-sticky-below-header';
|
||||||
import { ChartRoot } from '@/components/report/chart';
|
import { ReportChart } from '@/components/report-chart';
|
||||||
import { ReportChartType } from '@/components/report/ReportChartType';
|
import { ReportChartType } from '@/components/report/ReportChartType';
|
||||||
import { ReportInterval } from '@/components/report/ReportInterval';
|
import { ReportInterval } from '@/components/report/ReportInterval';
|
||||||
import { ReportLineType } from '@/components/report/ReportLineType';
|
import { ReportLineType } from '@/components/report/ReportLineType';
|
||||||
@@ -99,7 +99,7 @@ export default function ReportEditor({
|
|||||||
</StickyBelowHeader>
|
</StickyBelowHeader>
|
||||||
<div className="flex flex-col gap-4 p-4" id="report-editor">
|
<div className="flex flex-col gap-4 p-4" id="report-editor">
|
||||||
{report.ready && (
|
{report.ready && (
|
||||||
<ChartRoot {...report} projectId={projectId} editMode />
|
<ReportChart report={{ ...report, projectId }} isEditMode />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<SheetContent className="!max-w-lg" side="left">
|
<SheetContent className="!max-w-lg" side="left">
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { getYAxisWidth } from '@/components/report/chart/chart-utils';
|
import {
|
||||||
import { ResponsiveContainer } from '@/components/report/chart/ResponsiveContainer';
|
useXAxisProps,
|
||||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
useYAxisProps,
|
||||||
|
} from '@/components/report-chart/common/axis';
|
||||||
import { getChartColor } from '@/utils/theme';
|
import { getChartColor } from '@/utils/theme';
|
||||||
import {
|
import {
|
||||||
Area,
|
Area,
|
||||||
AreaChart,
|
AreaChart,
|
||||||
Tooltip as RechartTooltip,
|
Tooltip as RechartTooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
@@ -39,13 +41,14 @@ function Tooltip(props: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Chart = ({ data }: Props) => {
|
const Chart = ({ data }: Props) => {
|
||||||
const max = Math.max(...data.map((d) => d.users));
|
const xAxisProps = useXAxisProps();
|
||||||
const number = useNumber();
|
const yAxisProps = useYAxisProps({
|
||||||
|
data: data.map((d) => d.users),
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="aspect-video max-h-[300px] w-full p-4">
|
||||||
<ResponsiveContainer>
|
<ResponsiveContainer>
|
||||||
{({ width, height }) => (
|
<AreaChart data={data}>
|
||||||
<AreaChart data={data} width={width} height={height}>
|
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop
|
<stop
|
||||||
@@ -71,11 +74,10 @@ const Chart = ({ data }: Props) => {
|
|||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
|
{...xAxisProps}
|
||||||
dataKey="days"
|
dataKey="days"
|
||||||
axisLine={false}
|
scale="auto"
|
||||||
fontSize={12}
|
type="category"
|
||||||
// type="number"
|
|
||||||
tickLine={false}
|
|
||||||
label={{
|
label={{
|
||||||
value: 'DAYS',
|
value: 'DAYS',
|
||||||
position: 'insideBottom',
|
position: 'insideBottom',
|
||||||
@@ -84,6 +86,7 @@ const Chart = ({ data }: Props) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
|
{...yAxisProps}
|
||||||
label={{
|
label={{
|
||||||
value: 'USERS',
|
value: 'USERS',
|
||||||
angle: -90,
|
angle: -90,
|
||||||
@@ -92,16 +95,8 @@ const Chart = ({ data }: Props) => {
|
|||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
}}
|
}}
|
||||||
dataKey="users"
|
dataKey="users"
|
||||||
fontSize={12}
|
|
||||||
axisLine={false}
|
|
||||||
tickLine={false}
|
|
||||||
width={getYAxisWidth(max)}
|
|
||||||
allowDecimals={false}
|
|
||||||
domain={[0, max]}
|
|
||||||
tickFormatter={number.short}
|
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
)}
|
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const Retention = ({ params: { projectId } }: Props) => {
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
<LastActiveUsersServer projectId={projectId} />
|
<LastActiveUsersServer projectId={projectId} />
|
||||||
<UsersRetentionSeries projectId={projectId} />
|
{/* <UsersRetentionSeries projectId={projectId} /> */}
|
||||||
<WeeklyCohortsServer projectId={projectId} />
|
<WeeklyCohortsServer projectId={projectId} />
|
||||||
</div>
|
</div>
|
||||||
</Padding>
|
</Padding>
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { getYAxisWidth } from '@/components/report/chart/chart-utils';
|
import {
|
||||||
import { ResponsiveContainer } from '@/components/report/chart/ResponsiveContainer';
|
useXAxisProps,
|
||||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
useYAxisProps,
|
||||||
|
} from '@/components/report-chart/common/axis';
|
||||||
import { getChartColor } from '@/utils/theme';
|
import { getChartColor } from '@/utils/theme';
|
||||||
import {
|
import {
|
||||||
Area,
|
Area,
|
||||||
AreaChart,
|
AreaChart,
|
||||||
Tooltip as RechartTooltip,
|
Tooltip as RechartTooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
@@ -50,19 +52,20 @@ function Tooltip(props: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Chart = ({ data }: Props) => {
|
const Chart = ({ data }: Props) => {
|
||||||
const max = Math.max(...data.monthly.map((d) => d.users));
|
|
||||||
const number = useNumber();
|
|
||||||
const rechartData = data.daily.map((d) => ({
|
const rechartData = data.daily.map((d) => ({
|
||||||
date: d.date,
|
date: new Date(d.date).getTime(),
|
||||||
dau: d.users,
|
dau: d.users,
|
||||||
wau: data.weekly.find((w) => w.date === d.date)?.users,
|
wau: data.weekly.find((w) => w.date === d.date)?.users,
|
||||||
mau: data.monthly.find((m) => m.date === d.date)?.users,
|
mau: data.monthly.find((m) => m.date === d.date)?.users,
|
||||||
}));
|
}));
|
||||||
|
const xAxisProps = useXAxisProps({ interval: 'day' });
|
||||||
|
const yAxisProps = useYAxisProps({
|
||||||
|
data: data.monthly.map((d) => d.users),
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="aspect-video max-h-[300px] w-full p-4">
|
||||||
<ResponsiveContainer>
|
<ResponsiveContainer>
|
||||||
{({ width, height }) => (
|
<AreaChart data={rechartData}>
|
||||||
<AreaChart data={rechartData} width={width} height={height}>
|
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="dau" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="dau" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop
|
<stop
|
||||||
@@ -125,14 +128,9 @@ const Chart = ({ data }: Props) => {
|
|||||||
fill={`url(#mau)`}
|
fill={`url(#mau)`}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
<XAxis
|
<XAxis {...xAxisProps} dataKey="date" />
|
||||||
dataKey="date"
|
|
||||||
axisLine={false}
|
|
||||||
fontSize={12}
|
|
||||||
// type="number"
|
|
||||||
tickLine={false}
|
|
||||||
/>
|
|
||||||
<YAxis
|
<YAxis
|
||||||
|
{...yAxisProps}
|
||||||
label={{
|
label={{
|
||||||
value: 'UNIQUE USERS',
|
value: 'UNIQUE USERS',
|
||||||
angle: -90,
|
angle: -90,
|
||||||
@@ -140,16 +138,8 @@ const Chart = ({ data }: Props) => {
|
|||||||
offset: 0,
|
offset: 0,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
}}
|
}}
|
||||||
fontSize={12}
|
|
||||||
axisLine={false}
|
|
||||||
tickLine={false}
|
|
||||||
width={getYAxisWidth(max)}
|
|
||||||
allowDecimals={false}
|
|
||||||
domain={[0, max]}
|
|
||||||
tickFormatter={number.short}
|
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
)}
|
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { getYAxisWidth } from '@/components/report/chart/chart-utils';
|
import {
|
||||||
import { ResponsiveContainer } from '@/components/report/chart/ResponsiveContainer';
|
useXAxisProps,
|
||||||
|
useYAxisProps,
|
||||||
|
} from '@/components/report-chart/common/axis';
|
||||||
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
||||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
|
||||||
import { formatDate } from '@/utils/date';
|
import { formatDate } from '@/utils/date';
|
||||||
import { getChartColor } from '@/utils/theme';
|
import { getChartColor } from '@/utils/theme';
|
||||||
import {
|
import {
|
||||||
Area,
|
Area,
|
||||||
AreaChart,
|
AreaChart,
|
||||||
Tooltip as RechartTooltip,
|
Tooltip as RechartTooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
@@ -54,13 +56,14 @@ function Tooltip({ payload }: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Chart = ({ data }: Props) => {
|
const Chart = ({ data }: Props) => {
|
||||||
const max = Math.max(...data.map((d) => d.retention));
|
const xAxisProps = useXAxisProps();
|
||||||
const number = useNumber();
|
const yAxisProps = useYAxisProps({
|
||||||
|
data: data.map((d) => d.retention),
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="aspect-video max-h-[300px] w-full p-4">
|
||||||
<ResponsiveContainer>
|
<ResponsiveContainer>
|
||||||
{({ width, height }) => (
|
<AreaChart data={data}>
|
||||||
<AreaChart data={data} width={width} height={height}>
|
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop
|
<stop
|
||||||
@@ -86,11 +89,9 @@ const Chart = ({ data }: Props) => {
|
|||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
axisLine={false}
|
{...xAxisProps}
|
||||||
fontSize={12}
|
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
tickFormatter={(m: string) => formatDate(new Date(m))}
|
tickFormatter={(m: string) => formatDate(new Date(m))}
|
||||||
tickLine={false}
|
|
||||||
allowDuplicatedCategory={false}
|
allowDuplicatedCategory={false}
|
||||||
label={{
|
label={{
|
||||||
value: 'DATE',
|
value: 'DATE',
|
||||||
@@ -100,6 +101,7 @@ const Chart = ({ data }: Props) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
|
{...yAxisProps}
|
||||||
label={{
|
label={{
|
||||||
value: 'RETENTION (%)',
|
value: 'RETENTION (%)',
|
||||||
angle: -90,
|
angle: -90,
|
||||||
@@ -107,16 +109,8 @@ const Chart = ({ data }: Props) => {
|
|||||||
offset: 0,
|
offset: 0,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
}}
|
}}
|
||||||
fontSize={12}
|
|
||||||
axisLine={false}
|
|
||||||
tickLine={false}
|
|
||||||
width={getYAxisWidth(max)}
|
|
||||||
allowDecimals={false}
|
|
||||||
domain={[0, max]}
|
|
||||||
tickFormatter={number.short}
|
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
)}
|
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { SerieIcon } from '@/components/report/chart/SerieIcon';
|
|
||||||
import { Tooltiper } from '@/components/ui/tooltip';
|
import { Tooltiper } from '@/components/ui/tooltip';
|
||||||
import { useAppParams } from '@/hooks/useAppParams';
|
import { useAppParams } from '@/hooks/useAppParams';
|
||||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||||
@@ -11,6 +10,7 @@ import Link from 'next/link';
|
|||||||
|
|
||||||
import type { IServiceEvent, IServiceEventMinimal } from '@openpanel/db';
|
import type { IServiceEvent, IServiceEventMinimal } from '@openpanel/db';
|
||||||
|
|
||||||
|
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||||
import { EventIcon } from './event-icon';
|
import { EventIcon } from './event-icon';
|
||||||
|
|
||||||
type EventListItemProps = IServiceEventMinimal | IServiceEvent;
|
type EventListItemProps = IServiceEventMinimal | IServiceEvent;
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import { SerieIcon } from '../report/chart/SerieIcon';
|
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||||
import {
|
import { Tooltiper } from '../ui/tooltip';
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
Tooltiper,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '../ui/tooltip';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
country?: string;
|
country?: string;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { EventIcon } from '@/components/events/event-icon';
|
import { EventIcon } from '@/components/events/event-icon';
|
||||||
import { ProjectLink } from '@/components/links';
|
import { ProjectLink } from '@/components/links';
|
||||||
import { SerieIcon } from '@/components/report/chart/SerieIcon';
|
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||||
import { TooltipComplete } from '@/components/tooltip-complete';
|
import { TooltipComplete } from '@/components/tooltip-complete';
|
||||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||||
import { pushModal } from '@/modals';
|
import { pushModal } from '@/modals';
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import { useProfileProperties } from '@/hooks/useProfileProperties';
|
import { useProfileProperties } from '@/hooks/useProfileProperties';
|
||||||
import { useProfileValues } from '@/hooks/useProfileValues';
|
import { useProfileValues } from '@/hooks/useProfileValues';
|
||||||
import { usePropertyValues } from '@/hooks/usePropertyValues';
|
import { usePropertyValues } from '@/hooks/usePropertyValues';
|
||||||
import { GlobeIcon, XIcon } from 'lucide-react';
|
import { XIcon } from 'lucide-react';
|
||||||
import type { Options as NuqsOptions } from 'nuqs';
|
import type { Options as NuqsOptions } from 'nuqs';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export function OverviewLiveHistogram({
|
|||||||
{staticArray.map((percent, i) => (
|
{staticArray.map((percent, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="flex-1 animate-pulse rounded bg-def-200"
|
className="flex-1 animate-pulse rounded-t bg-def-200"
|
||||||
style={{ height: `${percent}%` }}
|
style={{ height: `${percent}%` }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -101,7 +101,7 @@ export function OverviewLiveHistogram({
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 rounded transition-all ease-in-out hover:scale-110',
|
'flex-1 rounded-t transition-all ease-in-out hover:scale-110',
|
||||||
minute.count === 0 ? 'bg-def-200' : 'bg-highlight'
|
minute.count === 0 ? 'bg-def-200' : 'bg-highlight'
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { ChartRoot } from '@/components/report/chart';
|
|
||||||
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
import type { IChartProps } from '@openpanel/validation';
|
import type { IChartProps } from '@openpanel/validation';
|
||||||
|
|
||||||
|
import { ReportChart } from '../report-chart';
|
||||||
import { OverviewLiveHistogram } from './overview-live-histogram';
|
import { OverviewLiveHistogram } from './overview-live-histogram';
|
||||||
|
|
||||||
interface OverviewMetricsProps {
|
interface OverviewMetricsProps {
|
||||||
@@ -151,6 +151,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
|||||||
formula: 'A/B*100',
|
formula: 'A/B*100',
|
||||||
metric: 'average',
|
metric: 'average',
|
||||||
unit: '%',
|
unit: '%',
|
||||||
|
maxDomain: 100,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'Visit duration',
|
id: 'Visit duration',
|
||||||
@@ -186,7 +187,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
|||||||
metric: 'average',
|
metric: 'average',
|
||||||
unit: 'min',
|
unit: 'min',
|
||||||
},
|
},
|
||||||
] satisfies (IChartProps & { id: string })[];
|
] satisfies (IChartProps & { id: string; maxDomain?: number })[];
|
||||||
|
|
||||||
const selectedMetric = reports[metric]!;
|
const selectedMetric = reports[metric]!;
|
||||||
|
|
||||||
@@ -198,30 +199,32 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
|||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
className={cn(
|
className={cn(
|
||||||
'col-span-2 flex-1 p-4 shadow-[0_0_0_0.5px] shadow-border md:col-span-1',
|
'col-span-2 flex-1 shadow-[0_0_0_0.5px] shadow-border md:col-span-1',
|
||||||
index === metric && 'bg-def-100'
|
index === metric && 'bg-def-100'
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMetric(index);
|
setMetric(index);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ChartRoot hideID {...report} />
|
<ReportChart report={report} options={{ hideID: true }} />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'col-span-4 min-h-28 flex-1 p-4 shadow-[0_0_0_0.5px] shadow-border max-md:row-start-1 md:col-span-2'
|
'col-span-4 min-h-16 flex-1 p-4 pb-0 shadow-[0_0_0_0.5px] shadow-border max-md:row-start-1 md:col-span-2'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<OverviewLiveHistogram projectId={projectId} />
|
<OverviewLiveHistogram projectId={projectId} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card col-span-6 p-4">
|
<div className="card col-span-6 p-4">
|
||||||
<ChartRoot
|
<ReportChart
|
||||||
key={selectedMetric.id}
|
key={selectedMetric.id}
|
||||||
hideID
|
options={{ hideID: true, maxDomain: selectedMetric.maxDomain }}
|
||||||
{...selectedMetric}
|
report={{
|
||||||
chartType="linear"
|
...selectedMetric,
|
||||||
|
chartType: 'linear',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { cn } from '@/utils/cn';
|
|||||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||||
import type { IChartType } from '@openpanel/validation';
|
import type { IChartType } from '@openpanel/validation';
|
||||||
|
|
||||||
import { LazyChart } from '../report/chart/LazyChart';
|
import { ReportChart } from '../report-chart';
|
||||||
import { Widget, WidgetBody } from '../widget';
|
import { Widget, WidgetBody } from '../widget';
|
||||||
import { OverviewChartToggle } from './overview-chart-toggle';
|
import { OverviewChartToggle } from './overview-chart-toggle';
|
||||||
import OverviewDetailsButton from './overview-details-button';
|
import OverviewDetailsButton from './overview-details-button';
|
||||||
@@ -31,6 +31,7 @@ export default function OverviewTopDevices({
|
|||||||
title: 'Top devices',
|
title: 'Top devices',
|
||||||
btn: 'Devices',
|
btn: 'Devices',
|
||||||
chart: {
|
chart: {
|
||||||
|
report: {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -58,10 +59,12 @@ export default function OverviewTopDevices({
|
|||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
browser: {
|
browser: {
|
||||||
title: 'Top browser',
|
title: 'Top browser',
|
||||||
btn: 'Browser',
|
btn: 'Browser',
|
||||||
chart: {
|
chart: {
|
||||||
|
report: {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -89,13 +92,17 @@ export default function OverviewTopDevices({
|
|||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
browser_version: {
|
browser_version: {
|
||||||
title: 'Top Browser Version',
|
title: 'Top Browser Version',
|
||||||
btn: 'Browser Version',
|
btn: 'Browser Version',
|
||||||
chart: {
|
chart: {
|
||||||
|
options: {
|
||||||
renderSerieName(name) {
|
renderSerieName(name) {
|
||||||
return name[1] || NOT_SET_VALUE;
|
return name[1] || NOT_SET_VALUE;
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
report: {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -127,10 +134,12 @@ export default function OverviewTopDevices({
|
|||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
os: {
|
os: {
|
||||||
title: 'Top OS',
|
title: 'Top OS',
|
||||||
btn: 'OS',
|
btn: 'OS',
|
||||||
chart: {
|
chart: {
|
||||||
|
report: {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -158,13 +167,17 @@ export default function OverviewTopDevices({
|
|||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
os_version: {
|
os_version: {
|
||||||
title: 'Top OS version',
|
title: 'Top OS version',
|
||||||
btn: 'OS Version',
|
btn: 'OS Version',
|
||||||
chart: {
|
chart: {
|
||||||
|
options: {
|
||||||
renderSerieName(name) {
|
renderSerieName(name) {
|
||||||
return name[1] || NOT_SET_VALUE;
|
return name[1] || NOT_SET_VALUE;
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
report: {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -196,10 +209,12 @@ export default function OverviewTopDevices({
|
|||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
brands: {
|
brands: {
|
||||||
title: 'Top Brands',
|
title: 'Top Brands',
|
||||||
btn: 'Brands',
|
btn: 'Brands',
|
||||||
chart: {
|
chart: {
|
||||||
|
report: {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -227,13 +242,17 @@ export default function OverviewTopDevices({
|
|||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
models: {
|
models: {
|
||||||
title: 'Top Models',
|
title: 'Top Models',
|
||||||
btn: 'Models',
|
btn: 'Models',
|
||||||
chart: {
|
chart: {
|
||||||
|
options: {
|
||||||
renderSerieName(name) {
|
renderSerieName(name) {
|
||||||
return name[1] || NOT_SET_VALUE;
|
return name[1] || NOT_SET_VALUE;
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
report: {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -265,6 +284,7 @@ export default function OverviewTopDevices({
|
|||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -285,11 +305,11 @@ export default function OverviewTopDevices({
|
|||||||
</WidgetButtons>
|
</WidgetButtons>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
<LazyChart
|
<ReportChart
|
||||||
hideID
|
options={{
|
||||||
{...widget.chart}
|
...widget.chart.options,
|
||||||
previous={false}
|
hideID: true,
|
||||||
onClick={(item) => {
|
onClick: (item) => {
|
||||||
switch (widget.key) {
|
switch (widget.key) {
|
||||||
case 'devices':
|
case 'devices':
|
||||||
setFilter('device', item.names[0]);
|
setFilter('device', item.names[0]);
|
||||||
@@ -307,11 +327,16 @@ export default function OverviewTopDevices({
|
|||||||
setFilter('os_version', item.names[1]);
|
setFilter('os_version', item.names[1]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
report={{
|
||||||
|
...widget.chart.report,
|
||||||
|
previous: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</WidgetBody>
|
</WidgetBody>
|
||||||
<WidgetFooter>
|
<WidgetFooter>
|
||||||
<OverviewDetailsButton chart={widget.chart} />
|
<OverviewDetailsButton chart={widget.chart.report} />
|
||||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
<OverviewChartToggle {...{ chartType, setChartType }} />
|
||||||
</WidgetFooter>
|
</WidgetFooter>
|
||||||
</Widget>
|
</Widget>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { LazyChart } from '@/components/report/chart/LazyChart';
|
import { ReportChart } from '@/components/report-chart';
|
||||||
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ export default function OverviewTopEvents({
|
|||||||
title: 'Top events',
|
title: 'Top events',
|
||||||
btn: 'Your',
|
btn: 'Your',
|
||||||
chart: {
|
chart: {
|
||||||
|
report: {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -66,10 +67,12 @@ export default function OverviewTopEvents({
|
|||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
all: {
|
all: {
|
||||||
title: 'Top events',
|
title: 'Top events',
|
||||||
btn: 'All',
|
btn: 'All',
|
||||||
chart: {
|
chart: {
|
||||||
|
report: {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -97,11 +100,13 @@ export default function OverviewTopEvents({
|
|||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
conversions: {
|
conversions: {
|
||||||
title: 'Conversions',
|
title: 'Conversions',
|
||||||
btn: 'Conversions',
|
btn: 'Conversions',
|
||||||
hide: conversions.length === 0,
|
hide: conversions.length === 0,
|
||||||
chart: {
|
chart: {
|
||||||
|
report: {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -137,6 +142,7 @@ export default function OverviewTopEvents({
|
|||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -159,10 +165,16 @@ export default function OverviewTopEvents({
|
|||||||
</WidgetButtons>
|
</WidgetButtons>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
<LazyChart hideID {...widget.chart} previous={false} />
|
<ReportChart
|
||||||
|
options={{ hideID: true }}
|
||||||
|
report={{
|
||||||
|
...widget.chart.report,
|
||||||
|
previous: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</WidgetBody>
|
</WidgetBody>
|
||||||
<WidgetFooter>
|
<WidgetFooter>
|
||||||
<OverviewDetailsButton chart={widget.chart} />
|
<OverviewDetailsButton chart={widget.chart.report} />
|
||||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
<OverviewChartToggle {...{ chartType, setChartType }} />
|
||||||
</WidgetFooter>
|
</WidgetFooter>
|
||||||
</Widget>
|
</Widget>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ChartRoot } from '@/components/report/chart';
|
|
||||||
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||||
import { getCountry } from '@/translations/countries';
|
import { getCountry } from '@/translations/countries';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
@@ -9,7 +8,7 @@ import { cn } from '@/utils/cn';
|
|||||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||||
import type { IChartType } from '@openpanel/validation';
|
import type { IChartType } from '@openpanel/validation';
|
||||||
|
|
||||||
import { LazyChart } from '../report/chart/LazyChart';
|
import { ReportChart } from '../report-chart';
|
||||||
import { Widget, WidgetBody } from '../widget';
|
import { Widget, WidgetBody } from '../widget';
|
||||||
import { OverviewChartToggle } from './overview-chart-toggle';
|
import { OverviewChartToggle } from './overview-chart-toggle';
|
||||||
import OverviewDetailsButton from './overview-details-button';
|
import OverviewDetailsButton from './overview-details-button';
|
||||||
@@ -31,9 +30,12 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
|||||||
title: 'Top countries',
|
title: 'Top countries',
|
||||||
btn: 'Countries',
|
btn: 'Countries',
|
||||||
chart: {
|
chart: {
|
||||||
|
options: {
|
||||||
renderSerieName(name) {
|
renderSerieName(name) {
|
||||||
return getCountry(name[0]) || NOT_SET_VALUE;
|
return getCountry(name[0]) || NOT_SET_VALUE;
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
report: {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -61,13 +63,17 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
|||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
regions: {
|
regions: {
|
||||||
title: 'Top regions',
|
title: 'Top regions',
|
||||||
btn: 'Regions',
|
btn: 'Regions',
|
||||||
chart: {
|
chart: {
|
||||||
|
options: {
|
||||||
renderSerieName(name) {
|
renderSerieName(name) {
|
||||||
return name[1] || NOT_SET_VALUE;
|
return name[1] || NOT_SET_VALUE;
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
report: {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -99,13 +105,17 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
|||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
cities: {
|
cities: {
|
||||||
title: 'Top cities',
|
title: 'Top cities',
|
||||||
btn: 'Cities',
|
btn: 'Cities',
|
||||||
chart: {
|
chart: {
|
||||||
|
options: {
|
||||||
renderSerieName(name) {
|
renderSerieName(name) {
|
||||||
return name[1] || NOT_SET_VALUE;
|
return name[1] || NOT_SET_VALUE;
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
report: {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -137,6 +147,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
|||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -157,11 +168,10 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
|||||||
</WidgetButtons>
|
</WidgetButtons>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
<LazyChart
|
<ReportChart
|
||||||
hideID
|
options={{
|
||||||
{...widget.chart}
|
hideID: true,
|
||||||
previous={false}
|
onClick: (item) => {
|
||||||
onClick={(item) => {
|
|
||||||
switch (widget.key) {
|
switch (widget.key) {
|
||||||
case 'countries':
|
case 'countries':
|
||||||
setWidget('regions');
|
setWidget('regions');
|
||||||
@@ -175,11 +185,17 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
|||||||
setFilter('city', item.names[1]);
|
setFilter('city', item.names[1]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
...widget.chart.options,
|
||||||
|
}}
|
||||||
|
report={{
|
||||||
|
...widget.chart.report,
|
||||||
|
previous: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</WidgetBody>
|
</WidgetBody>
|
||||||
<WidgetFooter>
|
<WidgetFooter>
|
||||||
<OverviewDetailsButton chart={widget.chart} />
|
<OverviewDetailsButton chart={widget.chart.report} />
|
||||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
<OverviewChartToggle {...{ chartType, setChartType }} />
|
||||||
</WidgetFooter>
|
</WidgetFooter>
|
||||||
</Widget>
|
</Widget>
|
||||||
@@ -188,9 +204,9 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
|||||||
<div className="title">Map</div>
|
<div className="title">Map</div>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
<ChartRoot
|
<ReportChart
|
||||||
hideID
|
options={{ hideID: true }}
|
||||||
{...{
|
report={{
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { parseAsBoolean, useQueryState } from 'nuqs';
|
|||||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||||
import type { IChartType } from '@openpanel/validation';
|
import type { IChartType } from '@openpanel/validation';
|
||||||
|
|
||||||
import { LazyChart } from '../report/chart/LazyChart';
|
import { ReportChart } from '../report-chart';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Tooltiper } from '../ui/tooltip';
|
import { Tooltiper } from '../ui/tooltip';
|
||||||
import { Widget, WidgetBody } from '../widget';
|
import { Widget, WidgetBody } from '../widget';
|
||||||
@@ -31,6 +31,10 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
|||||||
const [domain, setDomain] = useQueryState('d', parseAsBoolean);
|
const [domain, setDomain] = useQueryState('d', parseAsBoolean);
|
||||||
const renderSerieName = (names: string[]) => {
|
const renderSerieName = (names: string[]) => {
|
||||||
if (domain) {
|
if (domain) {
|
||||||
|
if (names[0] === NOT_SET_VALUE) {
|
||||||
|
return names[1];
|
||||||
|
}
|
||||||
|
|
||||||
return names.join('');
|
return names.join('');
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@@ -44,7 +48,10 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
|||||||
title: 'Top pages',
|
title: 'Top pages',
|
||||||
btn: 'Top pages',
|
btn: 'Top pages',
|
||||||
chart: {
|
chart: {
|
||||||
|
options: {
|
||||||
renderSerieName,
|
renderSerieName,
|
||||||
|
},
|
||||||
|
report: {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -76,11 +83,15 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
|||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
entries: {
|
entries: {
|
||||||
title: 'Entry Pages',
|
title: 'Entry Pages',
|
||||||
btn: 'Entries',
|
btn: 'Entries',
|
||||||
chart: {
|
chart: {
|
||||||
|
options: {
|
||||||
renderSerieName,
|
renderSerieName,
|
||||||
|
},
|
||||||
|
report: {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -112,11 +123,15 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
|||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
exits: {
|
exits: {
|
||||||
title: 'Exit Pages',
|
title: 'Exit Pages',
|
||||||
btn: 'Exits',
|
btn: 'Exits',
|
||||||
chart: {
|
chart: {
|
||||||
|
options: {
|
||||||
renderSerieName,
|
renderSerieName,
|
||||||
|
},
|
||||||
|
report: {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -148,6 +163,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
|||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
bot: {
|
bot: {
|
||||||
title: 'Bots',
|
title: 'Bots',
|
||||||
btn: 'Bots',
|
btn: 'Bots',
|
||||||
@@ -177,11 +193,10 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
|||||||
{widget.key === 'bot' ? (
|
{widget.key === 'bot' ? (
|
||||||
<OverviewTopBots projectId={projectId} />
|
<OverviewTopBots projectId={projectId} />
|
||||||
) : (
|
) : (
|
||||||
<LazyChart
|
<ReportChart
|
||||||
hideID
|
options={{
|
||||||
{...widget.chart}
|
hideID: true,
|
||||||
previous={false}
|
dropdownMenuContent: (serie) => [
|
||||||
dropdownMenuContent={(serie) => [
|
|
||||||
{
|
{
|
||||||
title: 'Visit page',
|
title: 'Visit page',
|
||||||
icon: ExternalLinkIcon,
|
icon: ExternalLinkIcon,
|
||||||
@@ -196,13 +211,19 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
|||||||
setFilter('path', serie.names[1]);
|
setFilter('path', serie.names[1]);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]}
|
],
|
||||||
|
...widget.chart.options,
|
||||||
|
}}
|
||||||
|
report={{
|
||||||
|
...widget.chart.report,
|
||||||
|
previous: false,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</WidgetBody>
|
</WidgetBody>
|
||||||
{widget.chart?.name && (
|
{widget.chart?.report?.name && (
|
||||||
<WidgetFooter>
|
<WidgetFooter>
|
||||||
<OverviewDetailsButton chart={widget.chart} />
|
<OverviewDetailsButton chart={widget.chart.report} />
|
||||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
<OverviewChartToggle {...{ chartType, setChartType }} />
|
||||||
<div className="flex-1"></div>
|
<div className="flex-1"></div>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { cn } from '@/utils/cn';
|
|||||||
|
|
||||||
import type { IChartType } from '@openpanel/validation';
|
import type { IChartType } from '@openpanel/validation';
|
||||||
|
|
||||||
import { LazyChart } from '../report/chart/LazyChart';
|
import { ReportChart } from '../report-chart';
|
||||||
import { Widget, WidgetBody } from '../widget';
|
import { Widget, WidgetBody } from '../widget';
|
||||||
import { OverviewChartToggle } from './overview-chart-toggle';
|
import { OverviewChartToggle } from './overview-chart-toggle';
|
||||||
import OverviewDetailsButton from './overview-details-button';
|
import OverviewDetailsButton from './overview-details-button';
|
||||||
@@ -30,6 +30,7 @@ export default function OverviewTopSources({
|
|||||||
title: 'Top sources',
|
title: 'Top sources',
|
||||||
btn: 'All',
|
btn: 'All',
|
||||||
chart: {
|
chart: {
|
||||||
|
report: {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -57,10 +58,12 @@ export default function OverviewTopSources({
|
|||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
domain: {
|
domain: {
|
||||||
title: 'Top urls',
|
title: 'Top urls',
|
||||||
btn: 'URLs',
|
btn: 'URLs',
|
||||||
chart: {
|
chart: {
|
||||||
|
report: {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -88,10 +91,12 @@ export default function OverviewTopSources({
|
|||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
type: {
|
type: {
|
||||||
title: 'Top types',
|
title: 'Top types',
|
||||||
btn: 'Types',
|
btn: 'Types',
|
||||||
chart: {
|
chart: {
|
||||||
|
report: {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -119,10 +124,12 @@ export default function OverviewTopSources({
|
|||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
utm_source: {
|
utm_source: {
|
||||||
title: 'UTM Source',
|
title: 'UTM Source',
|
||||||
btn: 'Source',
|
btn: 'Source',
|
||||||
chart: {
|
chart: {
|
||||||
|
report: {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -150,10 +157,12 @@ export default function OverviewTopSources({
|
|||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
utm_medium: {
|
utm_medium: {
|
||||||
title: 'UTM Medium',
|
title: 'UTM Medium',
|
||||||
btn: 'Medium',
|
btn: 'Medium',
|
||||||
chart: {
|
chart: {
|
||||||
|
report: {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -181,10 +190,12 @@ export default function OverviewTopSources({
|
|||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
utm_campaign: {
|
utm_campaign: {
|
||||||
title: 'UTM Campaign',
|
title: 'UTM Campaign',
|
||||||
btn: 'Campaign',
|
btn: 'Campaign',
|
||||||
chart: {
|
chart: {
|
||||||
|
report: {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -212,10 +223,12 @@ export default function OverviewTopSources({
|
|||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
utm_term: {
|
utm_term: {
|
||||||
title: 'UTM Term',
|
title: 'UTM Term',
|
||||||
btn: 'Term',
|
btn: 'Term',
|
||||||
chart: {
|
chart: {
|
||||||
|
report: {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -243,10 +256,12 @@ export default function OverviewTopSources({
|
|||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
utm_content: {
|
utm_content: {
|
||||||
title: 'UTM Content',
|
title: 'UTM Content',
|
||||||
btn: 'Content',
|
btn: 'Content',
|
||||||
chart: {
|
chart: {
|
||||||
|
report: {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -274,6 +289,7 @@ export default function OverviewTopSources({
|
|||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -295,11 +311,13 @@ export default function OverviewTopSources({
|
|||||||
</WidgetButtons>
|
</WidgetButtons>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
<LazyChart
|
<ReportChart
|
||||||
hideID
|
report={{
|
||||||
{...widget.chart}
|
...widget.chart.report,
|
||||||
previous={false}
|
previous: false,
|
||||||
onClick={(item) => {
|
}}
|
||||||
|
options={{
|
||||||
|
onClick: (item) => {
|
||||||
switch (widget.key) {
|
switch (widget.key) {
|
||||||
case 'all':
|
case 'all':
|
||||||
setFilter('referrer_name', item.names[0]);
|
setFilter('referrer_name', item.names[0]);
|
||||||
@@ -328,11 +346,12 @@ export default function OverviewTopSources({
|
|||||||
setFilter('properties.__query.utm_content', item.names[0]);
|
setFilter('properties.__query.utm_content', item.names[0]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</WidgetBody>
|
</WidgetBody>
|
||||||
<WidgetFooter>
|
<WidgetFooter>
|
||||||
<OverviewDetailsButton chart={widget.chart} />
|
<OverviewDetailsButton chart={widget.chart.report} />
|
||||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
<OverviewChartToggle {...{ chartType, setChartType }} />
|
||||||
</WidgetFooter>
|
</WidgetFooter>
|
||||||
</Widget>
|
</Widget>
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { parseAsStringEnum, useQueryState } from 'nuqs';
|
|||||||
|
|
||||||
import { mapKeys } from '@openpanel/validation';
|
import { mapKeys } from '@openpanel/validation';
|
||||||
|
|
||||||
import type { IChartRoot } from '../report/chart';
|
import type { ReportChartProps } from '../report-chart/context';
|
||||||
|
|
||||||
export function useOverviewWidget<T extends string>(
|
export function useOverviewWidget<T extends string>(
|
||||||
key: string,
|
key: string,
|
||||||
widgets: Record<
|
widgets: Record<
|
||||||
T,
|
T,
|
||||||
{ title: string; btn: string; chart: IChartRoot; hide?: boolean }
|
{ title: string; btn: string; chart: ReportChartProps; hide?: boolean }
|
||||||
>
|
>
|
||||||
) {
|
) {
|
||||||
const keys = Object.keys(widgets) as T[];
|
const keys = Object.keys(widgets) as T[];
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ProjectLink } from '@/components/links';
|
import { ProjectLink } from '@/components/links';
|
||||||
import { SerieIcon } from '@/components/report/chart/SerieIcon';
|
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||||
import { Tooltiper } from '@/components/ui/tooltip';
|
import { Tooltiper } from '@/components/ui/tooltip';
|
||||||
import { formatDateTime, formatTime } from '@/utils/date';
|
import { formatDateTime, formatTime } from '@/utils/date';
|
||||||
import { getProfileName } from '@/utils/getters';
|
import { getProfileName } from '@/utils/getters';
|
||||||
|
|||||||
279
apps/dashboard/src/components/report-chart/area/chart.tsx
Normal file
279
apps/dashboard/src/components/report-chart/area/chart.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
|
||||||
|
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
|
||||||
|
import { api } from '@/trpc/client';
|
||||||
|
import type { IChartData } from '@/trpc/client';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import { getChartColor } from '@/utils/theme';
|
||||||
|
import { isSameDay, isSameHour, isSameMonth } from 'date-fns';
|
||||||
|
import { last } from 'ramda';
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
CartesianGrid,
|
||||||
|
ComposedChart,
|
||||||
|
Legend,
|
||||||
|
Line,
|
||||||
|
ReferenceLine,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
||||||
|
import { ReportChartTooltip } from '../common/report-chart-tooltip';
|
||||||
|
import { ReportTable } from '../common/report-table';
|
||||||
|
import { SerieIcon } from '../common/serie-icon';
|
||||||
|
import { SerieName } from '../common/serie-name';
|
||||||
|
import { useReportChartContext } from '../context';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: IChartData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Chart({ data }: Props) {
|
||||||
|
const {
|
||||||
|
report: {
|
||||||
|
previous,
|
||||||
|
interval,
|
||||||
|
projectId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
range,
|
||||||
|
lineType,
|
||||||
|
},
|
||||||
|
isEditMode,
|
||||||
|
options: { hideXAxis, hideYAxis },
|
||||||
|
} = useReportChartContext();
|
||||||
|
const dataLength = data.series[0]?.data?.length || 0;
|
||||||
|
const references = api.reference.getChartReferences.useQuery(
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
range,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
staleTime: 1000 * 60 * 10,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||||
|
const rechartData = useRechartDataModel(series);
|
||||||
|
|
||||||
|
// great care should be taken when computing lastIntervalPercent
|
||||||
|
// the expression below works for data.length - 1 equal intervals
|
||||||
|
// but if there are numeric x values in a "linear" axis, the formula
|
||||||
|
// should be updated to use those values
|
||||||
|
const lastIntervalPercent =
|
||||||
|
((rechartData.length - 2) * 100) / (rechartData.length - 1);
|
||||||
|
|
||||||
|
const gradientTwoColors = (
|
||||||
|
id: string,
|
||||||
|
col1: string,
|
||||||
|
col2: string,
|
||||||
|
percentChange: number
|
||||||
|
) => (
|
||||||
|
<linearGradient id={id} x1="0" y1="0" x2="100%" y2="0">
|
||||||
|
<stop offset="0%" stopColor={col1} />
|
||||||
|
<stop offset={`${percentChange}%`} stopColor={col1} />
|
||||||
|
<stop offset={`${percentChange}%`} stopColor={`${col2}`} />
|
||||||
|
<stop offset="100%" stopColor={col2} />
|
||||||
|
</linearGradient>
|
||||||
|
);
|
||||||
|
|
||||||
|
const lastSerieDataItem = last(series[0]?.data || [])?.date || new Date();
|
||||||
|
const useDashedLastLine = (() => {
|
||||||
|
if (interval === 'hour') {
|
||||||
|
return isSameHour(lastSerieDataItem, new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interval === 'day') {
|
||||||
|
return isSameDay(lastSerieDataItem, new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interval === 'month') {
|
||||||
|
return isSameMonth(lastSerieDataItem, new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const CustomLegend = useCallback(() => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1">
|
||||||
|
{series.map((serie) => (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
key={serie.id}
|
||||||
|
style={{
|
||||||
|
color: getChartColor(serie.index),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SerieIcon name={serie.names} />
|
||||||
|
<SerieName name={serie.names} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [series]);
|
||||||
|
|
||||||
|
const yAxisProps = useYAxisProps({
|
||||||
|
data: [data.metrics.max],
|
||||||
|
hide: hideYAxis,
|
||||||
|
});
|
||||||
|
const xAxisProps = useXAxisProps({
|
||||||
|
hide: hideXAxis,
|
||||||
|
interval,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAreaStyle = series.length === 1;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||||
|
<ResponsiveContainer>
|
||||||
|
<ComposedChart data={rechartData}>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
horizontal={true}
|
||||||
|
vertical={false}
|
||||||
|
className="stroke-border"
|
||||||
|
/>
|
||||||
|
{references.data?.map((ref) => (
|
||||||
|
<ReferenceLine
|
||||||
|
key={ref.id}
|
||||||
|
x={ref.date.getTime()}
|
||||||
|
stroke={'#94a3b8'}
|
||||||
|
strokeDasharray={'3 3'}
|
||||||
|
label={{
|
||||||
|
value: ref.title,
|
||||||
|
position: 'centerTop',
|
||||||
|
fill: '#334155',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
fontSize={10}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<YAxis {...yAxisProps} />
|
||||||
|
<XAxis {...xAxisProps} />
|
||||||
|
{series.length > 1 && (
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: '10px' }}
|
||||||
|
content={<CustomLegend />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Tooltip content={<ReportChartTooltip />} />
|
||||||
|
{series.map((serie) => {
|
||||||
|
const color = getChartColor(serie.index);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={serie.id}>
|
||||||
|
<defs>
|
||||||
|
{isAreaStyle && (
|
||||||
|
<linearGradient
|
||||||
|
id={`color${color}`}
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="0"
|
||||||
|
y2="1"
|
||||||
|
>
|
||||||
|
<stop
|
||||||
|
offset="0%"
|
||||||
|
stopColor={color}
|
||||||
|
stopOpacity={0.8}
|
||||||
|
></stop>
|
||||||
|
<stop
|
||||||
|
offset="100%"
|
||||||
|
stopColor={color}
|
||||||
|
stopOpacity={0.1}
|
||||||
|
></stop>
|
||||||
|
</linearGradient>
|
||||||
|
)}
|
||||||
|
{gradientTwoColors(
|
||||||
|
`hideAllButLastInterval_${serie.id}`,
|
||||||
|
'rgba(0,0,0,0)',
|
||||||
|
color,
|
||||||
|
lastIntervalPercent
|
||||||
|
)}
|
||||||
|
{gradientTwoColors(
|
||||||
|
`hideJustLastInterval_${serie.id}`,
|
||||||
|
color,
|
||||||
|
'rgba(0,0,0,0)',
|
||||||
|
lastIntervalPercent
|
||||||
|
)}
|
||||||
|
</defs>
|
||||||
|
<Line
|
||||||
|
dot={isAreaStyle && dataLength <= 8}
|
||||||
|
type={lineType}
|
||||||
|
name={serie.id}
|
||||||
|
isAnimationActive={false}
|
||||||
|
strokeWidth={2}
|
||||||
|
dataKey={`${serie.id}:count`}
|
||||||
|
stroke={useDashedLastLine ? 'transparent' : color}
|
||||||
|
// Use for legend
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
{isAreaStyle && (
|
||||||
|
<Area
|
||||||
|
dot={false}
|
||||||
|
name={`${serie.id}:area:noTooltip`}
|
||||||
|
dataKey={`${serie.id}:count`}
|
||||||
|
fill={`url(#color${color})`}
|
||||||
|
type={lineType}
|
||||||
|
isAnimationActive={false}
|
||||||
|
fillOpacity={0.1}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{useDashedLastLine && (
|
||||||
|
<>
|
||||||
|
<Line
|
||||||
|
dot={false}
|
||||||
|
type={lineType}
|
||||||
|
name={`${serie.id}:dashed:noTooltip`}
|
||||||
|
isAnimationActive={false}
|
||||||
|
strokeWidth={2}
|
||||||
|
dataKey={`${serie.id}:count`}
|
||||||
|
stroke={`url('#hideAllButLastInterval_${serie.id}')`}
|
||||||
|
strokeDasharray="4 2"
|
||||||
|
strokeOpacity={0.7}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
dot={false}
|
||||||
|
type={lineType}
|
||||||
|
name={`${serie.id}:solid:noTooltip`}
|
||||||
|
isAnimationActive={false}
|
||||||
|
strokeWidth={2}
|
||||||
|
dataKey={`${serie.id}:count`}
|
||||||
|
stroke={`url('#hideJustLastInterval_${serie.id}')`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{previous && (
|
||||||
|
<Line
|
||||||
|
type={lineType}
|
||||||
|
name={`${serie.id}:prev`}
|
||||||
|
isAnimationActive
|
||||||
|
dot={false}
|
||||||
|
strokeOpacity={0.3}
|
||||||
|
dataKey={`${serie.id}:prev:count`}
|
||||||
|
stroke={color}
|
||||||
|
// Use for legend
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
{isEditMode && (
|
||||||
|
<ReportTable
|
||||||
|
data={data}
|
||||||
|
visibleSeries={series}
|
||||||
|
setVisibleSeries={setVisibleSeries}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
apps/dashboard/src/components/report-chart/area/index.tsx
Normal file
59
apps/dashboard/src/components/report-chart/area/index.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { api } from '@/trpc/client';
|
||||||
|
|
||||||
|
import { AspectContainer } from '../aspect-container';
|
||||||
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
|
import { ReportChartError } from '../common/error';
|
||||||
|
import { ReportChartLoading } from '../common/loading';
|
||||||
|
import { useReportChartContext } from '../context';
|
||||||
|
import { Chart } from './chart';
|
||||||
|
|
||||||
|
export function ReportAreaChart() {
|
||||||
|
const { isLazyLoading, report } = useReportChartContext();
|
||||||
|
|
||||||
|
const res = api.chart.chart.useQuery(report, {
|
||||||
|
keepPreviousData: true,
|
||||||
|
staleTime: 1000 * 60 * 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLazyLoading || res.isLoading || res.isFetching) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.isError) {
|
||||||
|
return <Error />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.data.series.length === 0) {
|
||||||
|
return <Empty />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AspectContainer>
|
||||||
|
<Chart data={res.data} />
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Loading() {
|
||||||
|
return (
|
||||||
|
<AspectContainer>
|
||||||
|
<ReportChartLoading />
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Error() {
|
||||||
|
return (
|
||||||
|
<AspectContainer>
|
||||||
|
<ReportChartError />
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Empty() {
|
||||||
|
return (
|
||||||
|
<AspectContainer>
|
||||||
|
<ReportChartEmpty />
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
|
import { DEFAULT_ASPECT_RATIO } from '@openpanel/constants';
|
||||||
|
|
||||||
|
import { useReportChartContext } from './context';
|
||||||
|
|
||||||
|
interface AspectContainerProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AspectContainer({ children, className }: AspectContainerProps) {
|
||||||
|
const { options } = useReportChartContext();
|
||||||
|
const minHeight = options?.minHeight ?? 100;
|
||||||
|
const maxHeight = options?.maxHeight ?? 300;
|
||||||
|
const aspectRatio = options?.aspectRatio ?? DEFAULT_ASPECT_RATIO;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('w-full', className)}
|
||||||
|
style={{
|
||||||
|
aspectRatio: 1 / aspectRatio,
|
||||||
|
maxHeight,
|
||||||
|
minHeight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,22 +15,25 @@ import { DropdownMenuPortal } from '@radix-ui/react-dropdown-menu';
|
|||||||
import { round } from '@openpanel/common';
|
import { round } from '@openpanel/common';
|
||||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||||
|
|
||||||
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
|
import { PreviousDiffIndicator } from '../common/previous-diff-indicator';
|
||||||
import { useChartContext } from './ChartProvider';
|
import { SerieIcon } from '../common/serie-icon';
|
||||||
import { SerieIcon } from './SerieIcon';
|
import { SerieName } from '../common/serie-name';
|
||||||
import { SerieName } from './SerieName';
|
import { useReportChartContext } from '../context';
|
||||||
|
|
||||||
interface ReportBarChartProps {
|
interface Props {
|
||||||
data: IChartData;
|
data: IChartData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReportBarChart({ data }: ReportBarChartProps) {
|
export function Chart({ data }: Props) {
|
||||||
const { editMode, metric, onClick, limit, dropdownMenuContent } =
|
const {
|
||||||
useChartContext();
|
isEditMode,
|
||||||
|
report: { metric, limit },
|
||||||
|
options: { onClick, dropdownMenuContent },
|
||||||
|
} = useReportChartContext();
|
||||||
const number = useNumber();
|
const number = useNumber();
|
||||||
const series = useMemo(
|
const series = useMemo(
|
||||||
() => (editMode ? data.series : data.series.slice(0, limit || 10)),
|
() => (isEditMode ? data.series : data.series.slice(0, limit || 10)),
|
||||||
[data, editMode, limit]
|
[data, isEditMode, limit]
|
||||||
);
|
);
|
||||||
const maxCount = Math.max(...series.map((serie) => serie.metrics[metric]));
|
const maxCount = Math.max(...series.map((serie) => serie.metrics[metric]));
|
||||||
|
|
||||||
@@ -38,7 +41,7 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-col text-sm',
|
'flex flex-col text-sm',
|
||||||
editMode ? 'card gap-2 p-4 text-base' : '-m-3 gap-1'
|
isEditMode ? 'card gap-2 p-4 text-base' : '-m-3 gap-1'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{series.map((serie) => {
|
{series.map((serie) => {
|
||||||
@@ -70,7 +73,7 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
|
|||||||
<SerieIcon name={serie.names[0]} />
|
<SerieIcon name={serie.names[0]} />
|
||||||
<SerieName name={serie.names} />
|
<SerieName name={serie.names} />
|
||||||
</div>
|
</div>
|
||||||
<div className="font-mono flex flex-shrink-0 items-center justify-end gap-4">
|
<div className="flex flex-shrink-0 items-center justify-end gap-4 font-mono">
|
||||||
<PreviousDiffIndicator
|
<PreviousDiffIndicator
|
||||||
{...serie.metrics.previous?.[metric]}
|
{...serie.metrics.previous?.[metric]}
|
||||||
/>
|
/>
|
||||||
@@ -103,66 +106,4 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</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>
|
|
||||||
// );
|
|
||||||
}
|
}
|
||||||
63
apps/dashboard/src/components/report-chart/bar/index.tsx
Normal file
63
apps/dashboard/src/components/report-chart/bar/index.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { api } from '@/trpc/client';
|
||||||
|
|
||||||
|
import { AspectContainer } from '../aspect-container';
|
||||||
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
|
import { ReportChartError } from '../common/error';
|
||||||
|
import { useReportChartContext } from '../context';
|
||||||
|
import { Chart } from './chart';
|
||||||
|
|
||||||
|
export function ReportBarChart() {
|
||||||
|
const { isLazyLoading, report } = useReportChartContext();
|
||||||
|
|
||||||
|
const res = api.chart.chart.useQuery(report, {
|
||||||
|
keepPreviousData: true,
|
||||||
|
staleTime: 1000 * 60 * 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLazyLoading || res.isLoading || res.isFetching) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.isError) {
|
||||||
|
return <Error />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.data.series.length === 0) {
|
||||||
|
return <Empty />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Chart data={res.data} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Loading() {
|
||||||
|
return (
|
||||||
|
<AspectContainer className="col gap-4 overflow-hidden">
|
||||||
|
{Array.from({ length: 10 }).map((_, index) => (
|
||||||
|
<div key={index} className="row animate-pulse justify-between">
|
||||||
|
<div className="h-4 w-2/5 rounded bg-def-200"></div>
|
||||||
|
<div className="row w-1/5 gap-2">
|
||||||
|
<div className="h-4 w-full rounded bg-def-200"></div>
|
||||||
|
<div className="h-4 w-full rounded bg-def-200"></div>
|
||||||
|
<div className="h-4 w-full rounded bg-def-200"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Error() {
|
||||||
|
return (
|
||||||
|
<AspectContainer>
|
||||||
|
<ReportChartError />
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Empty() {
|
||||||
|
return (
|
||||||
|
<AspectContainer>
|
||||||
|
<ReportChartEmpty />
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
apps/dashboard/src/components/report-chart/common/axis.tsx
Normal file
81
apps/dashboard/src/components/report-chart/common/axis.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useDebounceFn } from '@/hooks/useDebounceFn';
|
||||||
|
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
||||||
|
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||||
|
import { isNil } from 'ramda';
|
||||||
|
import type { AxisDomain } from 'recharts/types/util/types';
|
||||||
|
|
||||||
|
import type { IInterval } from '@openpanel/validation';
|
||||||
|
|
||||||
|
export const AXIS_FONT_PROPS = {
|
||||||
|
fontSize: 8,
|
||||||
|
className: 'font-mono',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getYAxisWidth(value: string | undefined | null) {
|
||||||
|
const charLength = AXIS_FONT_PROPS.fontSize * 0.6;
|
||||||
|
|
||||||
|
if (isNil(value) || value.length === 0) {
|
||||||
|
return charLength * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return charLength * value.length + charLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useYAxisProps = ({
|
||||||
|
data,
|
||||||
|
hide,
|
||||||
|
}: {
|
||||||
|
data: number[];
|
||||||
|
hide?: boolean;
|
||||||
|
}) => {
|
||||||
|
const [width, setWidth] = useState(24);
|
||||||
|
const setWidthDebounced = useDebounceFn(setWidth, 100);
|
||||||
|
const number = useNumber();
|
||||||
|
const ref = useRef<number[]>([]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...AXIS_FONT_PROPS,
|
||||||
|
width: hide ? 0 : width,
|
||||||
|
axisLine: false,
|
||||||
|
tickLine: false,
|
||||||
|
allowDecimals: false,
|
||||||
|
tickFormatter: (value: number) => {
|
||||||
|
const tick = number.short(value);
|
||||||
|
const newWidth = getYAxisWidth(tick);
|
||||||
|
ref.current.push(newWidth);
|
||||||
|
setWidthDebounced(Math.max(...ref.current));
|
||||||
|
return tick;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useXAxisProps = (
|
||||||
|
{
|
||||||
|
interval = 'auto',
|
||||||
|
hide,
|
||||||
|
}: {
|
||||||
|
interval?: IInterval | 'auto';
|
||||||
|
hide?: boolean;
|
||||||
|
} = {
|
||||||
|
hide: false,
|
||||||
|
interval: 'auto',
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const formatDate = useFormatDateInterval(
|
||||||
|
interval === 'auto' ? 'day' : interval
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
height: hide ? 0 : undefined,
|
||||||
|
axisLine: false,
|
||||||
|
dataKey: 'timestamp',
|
||||||
|
scale: 'utc',
|
||||||
|
domain: ['dataMin', 'dataMax'] as AxisDomain,
|
||||||
|
tickFormatter:
|
||||||
|
interval === 'auto' ? undefined : (m: string) => formatDate(new Date(m)),
|
||||||
|
type: 'number' as const,
|
||||||
|
tickLine: false,
|
||||||
|
minTickGap: 20,
|
||||||
|
...AXIS_FONT_PROPS,
|
||||||
|
} as const;
|
||||||
|
};
|
||||||
13
apps/dashboard/src/components/report-chart/common/empty.tsx
Normal file
13
apps/dashboard/src/components/report-chart/common/empty.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { BirdIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
export function ReportChartEmpty() {
|
||||||
|
return (
|
||||||
|
<div className="center-center h-full w-full flex-col">
|
||||||
|
<BirdIcon
|
||||||
|
strokeWidth={1.2}
|
||||||
|
className="mb-4 size-10 animate-pulse text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">No data</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/dashboard/src/components/report-chart/common/error.tsx
Normal file
15
apps/dashboard/src/components/report-chart/common/error.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { ServerCrashIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
export function ReportChartError() {
|
||||||
|
return (
|
||||||
|
<div className="center-center h-full w-full flex-col">
|
||||||
|
<ServerCrashIcon
|
||||||
|
strokeWidth={1.2}
|
||||||
|
className="mb-4 size-10 animate-pulse text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">
|
||||||
|
There was an error loading this chart.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function ReportChartLoading() {
|
||||||
|
return <div className="h-full w-full animate-pulse rounded bg-def-100"></div>;
|
||||||
|
}
|
||||||
@@ -1,14 +1,8 @@
|
|||||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import {
|
import { ArrowDownIcon, ArrowUpIcon } from 'lucide-react';
|
||||||
ArrowDownIcon,
|
|
||||||
ArrowUpIcon,
|
|
||||||
TrendingDownIcon,
|
|
||||||
TrendingUpIcon,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
import { Badge } from '../ui/badge';
|
import { useReportChartContext } from '../context';
|
||||||
import { useChartContext } from './chart/ChartProvider';
|
|
||||||
|
|
||||||
export function getDiffIndicator<A, B, C>(
|
export function getDiffIndicator<A, B, C>(
|
||||||
inverted: boolean | undefined,
|
inverted: boolean | undefined,
|
||||||
@@ -46,7 +40,9 @@ export function PreviousDiffIndicator({
|
|||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
}: PreviousDiffIndicatorProps) {
|
}: PreviousDiffIndicatorProps) {
|
||||||
const { previous, previousIndicatorInverted } = useChartContext();
|
const {
|
||||||
|
report: { previousIndicatorInverted, previous },
|
||||||
|
} = useReportChartContext();
|
||||||
const variant = getDiffIndicator(
|
const variant = getDiffIndicator(
|
||||||
inverted ?? previousIndicatorInverted,
|
inverted ?? previousIndicatorInverted,
|
||||||
state,
|
state,
|
||||||
@@ -74,7 +70,7 @@ export function PreviousDiffIndicator({
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-mono flex items-center gap-1 font-medium',
|
'flex items-center gap-1 font-mono font-medium',
|
||||||
size === 'lg' && 'gap-2',
|
size === 'lg' && 'gap-2',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@@ -7,10 +7,10 @@ import * as Portal from '@radix-ui/react-portal';
|
|||||||
import { bind } from 'bind-event-listener';
|
import { bind } from 'bind-event-listener';
|
||||||
import throttle from 'lodash.throttle';
|
import throttle from 'lodash.throttle';
|
||||||
|
|
||||||
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
|
import { useReportChartContext } from '../context';
|
||||||
import { useChartContext } from './ChartProvider';
|
import { PreviousDiffIndicator } from './previous-diff-indicator';
|
||||||
import { SerieIcon } from './SerieIcon';
|
import { SerieIcon } from './serie-icon';
|
||||||
import { SerieName } from './SerieName';
|
import { SerieName } from './serie-name';
|
||||||
|
|
||||||
type ReportLineChartTooltipProps = IToolTipProps<{
|
type ReportLineChartTooltipProps = IToolTipProps<{
|
||||||
value: number;
|
value: number;
|
||||||
@@ -23,7 +23,9 @@ export function ReportChartTooltip({
|
|||||||
active,
|
active,
|
||||||
payload,
|
payload,
|
||||||
}: ReportLineChartTooltipProps) {
|
}: ReportLineChartTooltipProps) {
|
||||||
const { unit, interval } = useChartContext();
|
const {
|
||||||
|
report: { interval, unit },
|
||||||
|
} = useReportChartContext();
|
||||||
const formatDate = useFormatDateInterval(interval);
|
const formatDate = useFormatDateInterval(interval);
|
||||||
const number = useNumber();
|
const number = useNumber();
|
||||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(
|
const [position, setPosition] = useState<{ x: number; y: number } | null>(
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Pagination, usePagination } from '@/components/pagination';
|
import { Pagination, usePagination } from '@/components/pagination';
|
||||||
|
import { Stats, StatsCard } from '@/components/stats';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import {
|
import {
|
||||||
@@ -10,22 +11,16 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import {
|
import { Tooltiper } from '@/components/ui/tooltip';
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
Tooltiper,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip';
|
|
||||||
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
||||||
import { useMappings } from '@/hooks/useMappings';
|
|
||||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||||
import { useSelector } from '@/redux';
|
import { useSelector } from '@/redux';
|
||||||
import { getPropertyLabel } from '@/translations/properties';
|
import { getPropertyLabel } from '@/translations/properties';
|
||||||
import type { IChartData } from '@/trpc/client';
|
import type { IChartData } from '@/trpc/client';
|
||||||
import { getChartColor } from '@/utils/theme';
|
import { getChartColor } from '@/utils/theme';
|
||||||
|
|
||||||
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
|
import { PreviousDiffIndicator } from './previous-diff-indicator';
|
||||||
import { SerieName } from './SerieName';
|
import { SerieName } from './serie-name';
|
||||||
|
|
||||||
interface ReportTableProps {
|
interface ReportTableProps {
|
||||||
data: IChartData;
|
data: IChartData;
|
||||||
@@ -58,6 +53,15 @@ export function ReportTable({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Stats className="my-4">
|
||||||
|
<StatsCard title="Total" value={number.format(data.metrics.sum)} />
|
||||||
|
<StatsCard
|
||||||
|
title="Average"
|
||||||
|
value={number.format(data.metrics.average)}
|
||||||
|
/>
|
||||||
|
<StatsCard title="Min" value={number.format(data.metrics.min)} />
|
||||||
|
<StatsCard title="Max" value={number.format(data.metrics.max)} />
|
||||||
|
</Stats>
|
||||||
<div className="grid grid-cols-[max(300px,30vw)_1fr] overflow-hidden rounded-md border border-border">
|
<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">
|
<Table className="rounded-none border-b-0 border-l-0 border-t-0">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -172,13 +176,8 @@ export function ReportTable({
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="row mt-4 justify-end">
|
||||||
<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
|
<Pagination
|
||||||
cursor={page}
|
cursor={page}
|
||||||
setCursor={setPage}
|
setCursor={setPage}
|
||||||
@@ -18,8 +18,8 @@ import {
|
|||||||
|
|
||||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||||
|
|
||||||
import flags from './SerieIcon.flags';
|
import flags from './serie-icon.flags';
|
||||||
import iconsWithUrls from './SerieIcon.urls';
|
import iconsWithUrls from './serie-icon.urls';
|
||||||
|
|
||||||
type SerieIconProps = Omit<LucideProps, 'name'> & {
|
type SerieIconProps = Omit<LucideProps, 'name'> & {
|
||||||
name?: string | string[];
|
name?: string | string[];
|
||||||
@@ -3,7 +3,7 @@ import { ChevronRightIcon } from 'lucide-react';
|
|||||||
|
|
||||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||||
|
|
||||||
import { useChartContext } from './ChartProvider';
|
import { useReportChartContext } from '../context';
|
||||||
|
|
||||||
interface SerieNameProps {
|
interface SerieNameProps {
|
||||||
name: string | string[];
|
name: string | string[];
|
||||||
@@ -11,10 +11,13 @@ interface SerieNameProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SerieName({ name, className }: SerieNameProps) {
|
export function SerieName({ name, className }: SerieNameProps) {
|
||||||
const chart = useChartContext();
|
const {
|
||||||
|
options: { renderSerieName },
|
||||||
|
} = useReportChartContext();
|
||||||
|
|
||||||
if (Array.isArray(name)) {
|
if (Array.isArray(name)) {
|
||||||
if (chart.renderSerieName) {
|
if (renderSerieName) {
|
||||||
return chart.renderSerieName(name);
|
return renderSerieName(name);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex items-center gap-1', className)}>
|
<div className={cn('flex items-center gap-1', className)}>
|
||||||
@@ -32,8 +35,8 @@ export function SerieName({ name, className }: SerieNameProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chart.renderSerieName) {
|
if (renderSerieName) {
|
||||||
return chart.renderSerieName([name]);
|
return renderSerieName([name]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{name}</>;
|
return <>{name}</>;
|
||||||
84
apps/dashboard/src/components/report-chart/context.tsx
Normal file
84
apps/dashboard/src/components/report-chart/context.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
import isEqual from 'lodash.isequal';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
IChartInput,
|
||||||
|
IChartProps,
|
||||||
|
IChartSerie,
|
||||||
|
} from '@openpanel/validation';
|
||||||
|
|
||||||
|
export type ReportChartContextType = {
|
||||||
|
options: Partial<{
|
||||||
|
hideID: boolean;
|
||||||
|
hideLegend: boolean;
|
||||||
|
hideXAxis: boolean;
|
||||||
|
hideYAxis: boolean;
|
||||||
|
aspectRatio: number;
|
||||||
|
maxHeight: number;
|
||||||
|
minHeight: number;
|
||||||
|
maxDomain: number;
|
||||||
|
onClick: (serie: IChartSerie) => void;
|
||||||
|
renderSerieName: (names: string[]) => React.ReactNode;
|
||||||
|
renderSerieIcon: (serie: IChartSerie) => React.ReactNode;
|
||||||
|
dropdownMenuContent: (serie: IChartSerie) => {
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}[];
|
||||||
|
}>;
|
||||||
|
report: IChartProps;
|
||||||
|
isLazyLoading: boolean;
|
||||||
|
isEditMode: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReportChartContextProviderProps = ReportChartContextType & {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReportChartProps = Partial<ReportChartContextType> & {
|
||||||
|
report: IChartInput;
|
||||||
|
};
|
||||||
|
|
||||||
|
const context = createContext<ReportChartContextType | null>(null);
|
||||||
|
|
||||||
|
export const useReportChartContext = () => {
|
||||||
|
const ctx = useContext(context);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error(
|
||||||
|
'useReportChartContext must be used within a ReportChartProvider'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSelectReportChartContext = <T,>(
|
||||||
|
selector: (ctx: ReportChartContextType) => T
|
||||||
|
) => {
|
||||||
|
const ctx = useReportChartContext();
|
||||||
|
const [state, setState] = useState(selector(ctx));
|
||||||
|
useEffect(() => {
|
||||||
|
const newState = selector(ctx);
|
||||||
|
if (!isEqual(newState, state)) {
|
||||||
|
setState(newState);
|
||||||
|
}
|
||||||
|
}, [ctx]);
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ReportChartProvider = ({
|
||||||
|
children,
|
||||||
|
...propsToContext
|
||||||
|
}: ReportChartContextProviderProps) => {
|
||||||
|
const [ctx, setContext] = useState(propsToContext);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEqual(ctx, propsToContext)) {
|
||||||
|
setContext(propsToContext);
|
||||||
|
}
|
||||||
|
}, [propsToContext]);
|
||||||
|
|
||||||
|
return <context.Provider value={ctx}>{children}</context.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default context;
|
||||||
@@ -12,10 +12,9 @@ import { last } from 'ramda';
|
|||||||
|
|
||||||
import { getPreviousMetric } from '@openpanel/common';
|
import { getPreviousMetric } from '@openpanel/common';
|
||||||
import { alphabetIds } from '@openpanel/constants';
|
import { alphabetIds } from '@openpanel/constants';
|
||||||
import type { IChartInput } from '@openpanel/validation';
|
|
||||||
|
|
||||||
import { useChartContext } from '../chart/ChartProvider';
|
import { PreviousDiffIndicator } from '../common/previous-diff-indicator';
|
||||||
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
|
import { useReportChartContext } from '../context';
|
||||||
|
|
||||||
const findMostDropoffs = (
|
const findMostDropoffs = (
|
||||||
steps: RouterOutputs['chart']['funnel']['current']['steps']
|
steps: RouterOutputs['chart']['funnel']['current']['steps']
|
||||||
@@ -28,43 +27,26 @@ const findMostDropoffs = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
function InsightCard({
|
type Props = {
|
||||||
title,
|
data: RouterOutputs['chart']['funnel'];
|
||||||
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({
|
export function Chart({
|
||||||
|
data: {
|
||||||
current: { steps, totalSessions },
|
current: { steps, totalSessions },
|
||||||
previous,
|
previous,
|
||||||
input,
|
},
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { editMode } = useChartContext();
|
const { isEditMode } = useReportChartContext();
|
||||||
const mostDropoffs = findMostDropoffs(steps);
|
const mostDropoffs = findMostDropoffs(steps);
|
||||||
const lastStep = last(steps)!;
|
const lastStep = last(steps)!;
|
||||||
const prevLastStep = last(previous.steps)!;
|
const prevLastStep = last(previous.steps);
|
||||||
const hasIncreased = lastStep.percent > prevLastStep.percent;
|
|
||||||
const withWidget = (children: React.ReactNode) => {
|
const withWidget = (children: React.ReactNode) => {
|
||||||
if (editMode) {
|
if (isEditMode) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
|
||||||
<Widget>
|
<Widget>
|
||||||
<WidgetBody>{children}</WidgetBody>
|
<WidgetBody>{children}</WidgetBody>
|
||||||
</Widget>
|
</Widget>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +56,10 @@ export function FunnelSteps({
|
|||||||
return withWidget(
|
return withWidget(
|
||||||
<div className="flex flex-col gap-4 @container">
|
<div className="flex flex-col gap-4 @container">
|
||||||
<div
|
<div
|
||||||
className={cn('border border-border', !editMode && 'border-0 border-b')}
|
className={cn(
|
||||||
|
'border border-border',
|
||||||
|
!isEditMode && 'border-0 border-b'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-8 p-4">
|
<div className="flex items-center gap-8 p-4">
|
||||||
<div className="hidden shrink-0 gap-2 @xl:flex">
|
<div className="hidden shrink-0 gap-2 @xl:flex">
|
||||||
@@ -103,13 +88,13 @@ export function FunnelSteps({
|
|||||||
<div className="text-xl text-muted-foreground">
|
<div className="text-xl text-muted-foreground">
|
||||||
Last period:{' '}
|
Last period:{' '}
|
||||||
<span className="font-semibold">
|
<span className="font-semibold">
|
||||||
{prevLastStep.count} of {previous.totalSessions}
|
{prevLastStep?.count} of {previous.totalSessions}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<PreviousDiffIndicator
|
<PreviousDiffIndicator
|
||||||
size="lg"
|
size="lg"
|
||||||
{...getPreviousMetric(lastStep.count, prevLastStep.count)}
|
{...getPreviousMetric(lastStep.count, prevLastStep?.count)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,7 +119,7 @@ export function FunnelSteps({
|
|||||||
<TooltipComplete
|
<TooltipComplete
|
||||||
disabled={!previous.steps[index]}
|
disabled={!previous.steps[index]}
|
||||||
content={
|
content={
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>
|
<span>
|
||||||
Last period:{' '}
|
Last period:{' '}
|
||||||
<span className="font-semibold">
|
<span className="font-semibold">
|
||||||
@@ -150,7 +135,7 @@ export function FunnelSteps({
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Total:
|
Total:
|
||||||
</span>
|
</span>
|
||||||
@@ -164,7 +149,7 @@ export function FunnelSteps({
|
|||||||
<TooltipComplete
|
<TooltipComplete
|
||||||
disabled={!previous.steps[index]}
|
disabled={!previous.steps[index]}
|
||||||
content={
|
content={
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>
|
<span>
|
||||||
Last period:{' '}
|
Last period:{' '}
|
||||||
<span className="font-semibold">
|
<span className="font-semibold">
|
||||||
@@ -181,7 +166,7 @@ export function FunnelSteps({
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Dropoff:
|
Dropoff:
|
||||||
</span>
|
</span>
|
||||||
@@ -201,7 +186,7 @@ export function FunnelSteps({
|
|||||||
<TooltipComplete
|
<TooltipComplete
|
||||||
disabled={!previous.steps[index]}
|
disabled={!previous.steps[index]}
|
||||||
content={
|
content={
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>
|
<span>
|
||||||
Last period:{' '}
|
Last period:{' '}
|
||||||
<span className="font-semibold">
|
<span className="font-semibold">
|
||||||
@@ -217,7 +202,7 @@ export function FunnelSteps({
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Current:
|
Current:
|
||||||
</span>
|
</span>
|
||||||
71
apps/dashboard/src/components/report-chart/funnel/index.tsx
Normal file
71
apps/dashboard/src/components/report-chart/funnel/index.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { api } from '@/trpc/client';
|
||||||
|
|
||||||
|
import type { IChartInput } from '@openpanel/validation';
|
||||||
|
|
||||||
|
import { AspectContainer } from '../aspect-container';
|
||||||
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
|
import { ReportChartError } from '../common/error';
|
||||||
|
import { ReportChartLoading } from '../common/loading';
|
||||||
|
import { useReportChartContext } from '../context';
|
||||||
|
import { Chart } from './chart';
|
||||||
|
|
||||||
|
export function ReportFunnelChart() {
|
||||||
|
const {
|
||||||
|
report: { events, range, projectId },
|
||||||
|
isLazyLoading,
|
||||||
|
} = useReportChartContext();
|
||||||
|
|
||||||
|
const input: IChartInput = {
|
||||||
|
events,
|
||||||
|
range,
|
||||||
|
projectId,
|
||||||
|
interval: 'day',
|
||||||
|
chartType: 'funnel',
|
||||||
|
breakdowns: [],
|
||||||
|
previous: false,
|
||||||
|
metric: 'sum',
|
||||||
|
};
|
||||||
|
const res = api.chart.funnel.useQuery(input, {
|
||||||
|
keepPreviousData: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLazyLoading || res.isLoading || res.isFetching) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.isError) {
|
||||||
|
return <Error />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.data.current.steps.length === 0) {
|
||||||
|
return <Empty />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Chart data={res.data} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Loading() {
|
||||||
|
return (
|
||||||
|
<AspectContainer>
|
||||||
|
<ReportChartLoading />
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Error() {
|
||||||
|
return (
|
||||||
|
<AspectContainer>
|
||||||
|
<ReportChartError />
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Empty() {
|
||||||
|
return (
|
||||||
|
<AspectContainer>
|
||||||
|
<ReportChartEmpty />
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
apps/dashboard/src/components/report-chart/histogram/chart.tsx
Normal file
130
apps/dashboard/src/components/report-chart/histogram/chart.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
|
||||||
|
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
|
||||||
|
import type { IChartData } from '@/trpc/client';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import { getChartColor, theme } from '@/utils/theme';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
||||||
|
import { ReportChartTooltip } from '../common/report-chart-tooltip';
|
||||||
|
import { ReportTable } from '../common/report-table';
|
||||||
|
import { useReportChartContext } from '../context';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: IChartData;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BarHover({ x, y, width, height, top, left, right, bottom }: any) {
|
||||||
|
const themeMode = useTheme();
|
||||||
|
const bg =
|
||||||
|
themeMode?.theme === 'dark'
|
||||||
|
? theme.colors['def-100']
|
||||||
|
: theme.colors['def-300'];
|
||||||
|
return (
|
||||||
|
<rect
|
||||||
|
{...{ x, y, width, height, top, left, right, bottom }}
|
||||||
|
rx="3"
|
||||||
|
fill={bg}
|
||||||
|
fillOpacity={0.5}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Chart({ data }: Props) {
|
||||||
|
const {
|
||||||
|
isEditMode,
|
||||||
|
report: { previous, interval },
|
||||||
|
options: { hideXAxis, hideYAxis },
|
||||||
|
} = useReportChartContext();
|
||||||
|
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||||
|
const rechartData = useRechartDataModel(series);
|
||||||
|
const yAxisProps = useYAxisProps({
|
||||||
|
data: [data.metrics.max],
|
||||||
|
hide: hideYAxis,
|
||||||
|
});
|
||||||
|
const xAxisProps = useXAxisProps({
|
||||||
|
hide: hideXAxis,
|
||||||
|
interval,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||||
|
<ResponsiveContainer>
|
||||||
|
<BarChart data={rechartData} barCategoryGap={10}>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
vertical={false}
|
||||||
|
className="stroke-def-200"
|
||||||
|
/>
|
||||||
|
<Tooltip content={<ReportChartTooltip />} cursor={<BarHover />} />
|
||||||
|
<YAxis {...yAxisProps} />
|
||||||
|
<XAxis {...xAxisProps} />
|
||||||
|
{series.map((serie) => {
|
||||||
|
return (
|
||||||
|
<React.Fragment key={serie.id}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id="colorGradient"
|
||||||
|
x1="0"
|
||||||
|
y1="1"
|
||||||
|
x2="0"
|
||||||
|
y2="0"
|
||||||
|
>
|
||||||
|
<stop
|
||||||
|
offset="0%"
|
||||||
|
stopColor={getChartColor(serie.index)}
|
||||||
|
stopOpacity={0.7}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="100%"
|
||||||
|
stopColor={getChartColor(serie.index)}
|
||||||
|
stopOpacity={1}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
{previous && (
|
||||||
|
<Bar
|
||||||
|
key={`${serie.id}:prev`}
|
||||||
|
name={`${serie.id}:prev`}
|
||||||
|
dataKey={`${serie.id}:prev:count`}
|
||||||
|
fill={getChartColor(serie.index)}
|
||||||
|
fillOpacity={0.1}
|
||||||
|
radius={3}
|
||||||
|
barSize={20} // Adjust the bar width here
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Bar
|
||||||
|
key={serie.id}
|
||||||
|
name={serie.id}
|
||||||
|
dataKey={`${serie.id}:count`}
|
||||||
|
fill="url(#colorGradient)"
|
||||||
|
radius={3}
|
||||||
|
fillOpacity={1}
|
||||||
|
barSize={20} // Adjust the bar width here
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
{isEditMode && (
|
||||||
|
<ReportTable
|
||||||
|
data={data}
|
||||||
|
visibleSeries={series}
|
||||||
|
setVisibleSeries={setVisibleSeries}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { api } from '@/trpc/client';
|
||||||
|
|
||||||
|
import { AspectContainer } from '../aspect-container';
|
||||||
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
|
import { ReportChartError } from '../common/error';
|
||||||
|
import { ReportChartLoading } from '../common/loading';
|
||||||
|
import { useReportChartContext } from '../context';
|
||||||
|
import { Chart } from './chart';
|
||||||
|
|
||||||
|
export function ReportHistogramChart() {
|
||||||
|
const { isLazyLoading, report } = useReportChartContext();
|
||||||
|
|
||||||
|
const res = api.chart.chart.useQuery(report, {
|
||||||
|
keepPreviousData: true,
|
||||||
|
staleTime: 1000 * 60 * 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLazyLoading || res.isLoading || res.isFetching) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.isError) {
|
||||||
|
return <Error />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.data.series.length === 0) {
|
||||||
|
return <Empty />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AspectContainer>
|
||||||
|
<Chart data={res.data} />
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Loading() {
|
||||||
|
return (
|
||||||
|
<AspectContainer>
|
||||||
|
<ReportChartLoading />
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Error() {
|
||||||
|
return (
|
||||||
|
<AspectContainer>
|
||||||
|
<ReportChartError />
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Empty() {
|
||||||
|
return (
|
||||||
|
<AspectContainer>
|
||||||
|
<ReportChartEmpty />
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
apps/dashboard/src/components/report-chart/index.tsx
Normal file
66
apps/dashboard/src/components/report-chart/index.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { mergeDeepRight } from 'ramda';
|
||||||
|
import { useInViewport } from 'react-in-viewport';
|
||||||
|
|
||||||
|
import { ReportAreaChart } from './area';
|
||||||
|
import { ReportBarChart } from './bar';
|
||||||
|
import type { ReportChartProps } from './context';
|
||||||
|
import { ReportChartProvider } from './context';
|
||||||
|
import { ReportFunnelChart } from './funnel';
|
||||||
|
import { ReportHistogramChart } from './histogram';
|
||||||
|
import { ReportLineChart } from './line';
|
||||||
|
import { ReportMapChart } from './map';
|
||||||
|
import { ReportMetricChart } from './metric';
|
||||||
|
import { ReportPieChart } from './pie';
|
||||||
|
|
||||||
|
export function ReportChart(props: ReportChartProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const once = useRef(false);
|
||||||
|
const { inViewport } = useInViewport(ref, undefined, {
|
||||||
|
disconnectOnLeave: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inViewport) {
|
||||||
|
once.current = true;
|
||||||
|
}
|
||||||
|
}, [inViewport]);
|
||||||
|
|
||||||
|
const loaded = once.current || inViewport;
|
||||||
|
|
||||||
|
const renderReportChart = () => {
|
||||||
|
switch (props.report.chartType) {
|
||||||
|
case 'linear':
|
||||||
|
return <ReportLineChart />;
|
||||||
|
case 'bar':
|
||||||
|
return <ReportBarChart />;
|
||||||
|
case 'area':
|
||||||
|
return <ReportAreaChart />;
|
||||||
|
case 'histogram':
|
||||||
|
return <ReportHistogramChart />;
|
||||||
|
case 'pie':
|
||||||
|
return <ReportPieChart />;
|
||||||
|
case 'map':
|
||||||
|
return <ReportMapChart />;
|
||||||
|
case 'metric':
|
||||||
|
return <ReportMetricChart />;
|
||||||
|
case 'funnel':
|
||||||
|
return <ReportFunnelChart />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref}>
|
||||||
|
<ReportChartProvider
|
||||||
|
{...mergeDeepRight({ options: {}, isEditMode: false }, props)}
|
||||||
|
isLazyLoading={!loaded}
|
||||||
|
>
|
||||||
|
{renderReportChart()}
|
||||||
|
</ReportChartProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
279
apps/dashboard/src/components/report-chart/line/chart.tsx
Normal file
279
apps/dashboard/src/components/report-chart/line/chart.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
|
||||||
|
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
|
||||||
|
import { api } from '@/trpc/client';
|
||||||
|
import type { IChartData } from '@/trpc/client';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import { getChartColor } from '@/utils/theme';
|
||||||
|
import { isSameDay, isSameHour, isSameMonth } from 'date-fns';
|
||||||
|
import { last } from 'ramda';
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
CartesianGrid,
|
||||||
|
ComposedChart,
|
||||||
|
Legend,
|
||||||
|
Line,
|
||||||
|
ReferenceLine,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
||||||
|
import { ReportChartTooltip } from '../common/report-chart-tooltip';
|
||||||
|
import { ReportTable } from '../common/report-table';
|
||||||
|
import { SerieIcon } from '../common/serie-icon';
|
||||||
|
import { SerieName } from '../common/serie-name';
|
||||||
|
import { useReportChartContext } from '../context';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: IChartData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Chart({ data }: Props) {
|
||||||
|
const {
|
||||||
|
report: {
|
||||||
|
previous,
|
||||||
|
interval,
|
||||||
|
projectId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
range,
|
||||||
|
lineType,
|
||||||
|
},
|
||||||
|
isEditMode,
|
||||||
|
options: { hideXAxis, hideYAxis, maxDomain },
|
||||||
|
} = useReportChartContext();
|
||||||
|
const dataLength = data.series[0]?.data?.length || 0;
|
||||||
|
const references = api.reference.getChartReferences.useQuery(
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
range,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
staleTime: 1000 * 60 * 10,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||||
|
const rechartData = useRechartDataModel(series);
|
||||||
|
|
||||||
|
// great care should be taken when computing lastIntervalPercent
|
||||||
|
// the expression below works for data.length - 1 equal intervals
|
||||||
|
// but if there are numeric x values in a "linear" axis, the formula
|
||||||
|
// should be updated to use those values
|
||||||
|
const lastIntervalPercent =
|
||||||
|
((rechartData.length - 2) * 100) / (rechartData.length - 1);
|
||||||
|
|
||||||
|
const gradientTwoColors = (
|
||||||
|
id: string,
|
||||||
|
col1: string,
|
||||||
|
col2: string,
|
||||||
|
percentChange: number
|
||||||
|
) => (
|
||||||
|
<linearGradient id={id} x1="0" y1="0" x2="100%" y2="0">
|
||||||
|
<stop offset="0%" stopColor={col1} />
|
||||||
|
<stop offset={`${percentChange}%`} stopColor={col1} />
|
||||||
|
<stop offset={`${percentChange}%`} stopColor={`${col2}`} />
|
||||||
|
<stop offset="100%" stopColor={col2} />
|
||||||
|
</linearGradient>
|
||||||
|
);
|
||||||
|
|
||||||
|
const lastSerieDataItem = last(series[0]?.data || [])?.date || new Date();
|
||||||
|
const useDashedLastLine = (() => {
|
||||||
|
if (interval === 'hour') {
|
||||||
|
return isSameHour(lastSerieDataItem, new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interval === 'day') {
|
||||||
|
return isSameDay(lastSerieDataItem, new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interval === 'month') {
|
||||||
|
return isSameMonth(lastSerieDataItem, new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const CustomLegend = useCallback(() => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1">
|
||||||
|
{series.map((serie) => (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
key={serie.id}
|
||||||
|
style={{
|
||||||
|
color: getChartColor(serie.index),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SerieIcon name={serie.names} />
|
||||||
|
<SerieName name={serie.names} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [series]);
|
||||||
|
|
||||||
|
const isAreaStyle = series.length === 1;
|
||||||
|
|
||||||
|
const xAxisProps = useXAxisProps({ interval, hide: hideXAxis });
|
||||||
|
const yAxisProps = useYAxisProps({
|
||||||
|
data: [data.metrics.max],
|
||||||
|
hide: hideYAxis,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||||
|
<ResponsiveContainer>
|
||||||
|
<ComposedChart data={rechartData}>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
horizontal={true}
|
||||||
|
vertical={false}
|
||||||
|
className="stroke-border"
|
||||||
|
/>
|
||||||
|
{references.data?.map((ref) => (
|
||||||
|
<ReferenceLine
|
||||||
|
key={ref.id}
|
||||||
|
x={ref.date.getTime()}
|
||||||
|
stroke={'#94a3b8'}
|
||||||
|
strokeDasharray={'3 3'}
|
||||||
|
label={{
|
||||||
|
value: ref.title,
|
||||||
|
position: 'centerTop',
|
||||||
|
fill: '#334155',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
fontSize={10}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<YAxis
|
||||||
|
{...yAxisProps}
|
||||||
|
domain={maxDomain ? [0, maxDomain] : undefined}
|
||||||
|
/>
|
||||||
|
<XAxis {...xAxisProps} />
|
||||||
|
{series.length > 1 && (
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: '10px' }}
|
||||||
|
content={<CustomLegend />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Tooltip content={<ReportChartTooltip />} />
|
||||||
|
{series.map((serie) => {
|
||||||
|
const color = getChartColor(serie.index);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={serie.id}>
|
||||||
|
<defs>
|
||||||
|
{isAreaStyle && (
|
||||||
|
<linearGradient
|
||||||
|
id={`color${color}`}
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="0"
|
||||||
|
y2="1"
|
||||||
|
>
|
||||||
|
<stop
|
||||||
|
offset="0%"
|
||||||
|
stopColor={color}
|
||||||
|
stopOpacity={0.8}
|
||||||
|
></stop>
|
||||||
|
<stop
|
||||||
|
offset="100%"
|
||||||
|
stopColor={color}
|
||||||
|
stopOpacity={0.1}
|
||||||
|
></stop>
|
||||||
|
</linearGradient>
|
||||||
|
)}
|
||||||
|
{gradientTwoColors(
|
||||||
|
`hideAllButLastInterval_${serie.id}`,
|
||||||
|
'rgba(0,0,0,0)',
|
||||||
|
color,
|
||||||
|
lastIntervalPercent
|
||||||
|
)}
|
||||||
|
{gradientTwoColors(
|
||||||
|
`hideJustLastInterval_${serie.id}`,
|
||||||
|
color,
|
||||||
|
'rgba(0,0,0,0)',
|
||||||
|
lastIntervalPercent
|
||||||
|
)}
|
||||||
|
</defs>
|
||||||
|
<Line
|
||||||
|
dot={isAreaStyle && dataLength <= 8}
|
||||||
|
type={lineType}
|
||||||
|
name={serie.id}
|
||||||
|
isAnimationActive={false}
|
||||||
|
strokeWidth={2}
|
||||||
|
dataKey={`${serie.id}:count`}
|
||||||
|
stroke={useDashedLastLine ? 'transparent' : color}
|
||||||
|
// Use for legend
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
{isAreaStyle && (
|
||||||
|
<Area
|
||||||
|
dot={false}
|
||||||
|
name={`${serie.id}:area:noTooltip`}
|
||||||
|
dataKey={`${serie.id}:count`}
|
||||||
|
fill={`url(#color${color})`}
|
||||||
|
type={lineType}
|
||||||
|
isAnimationActive={false}
|
||||||
|
fillOpacity={0.1}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{useDashedLastLine && (
|
||||||
|
<>
|
||||||
|
<Line
|
||||||
|
dot={false}
|
||||||
|
type={lineType}
|
||||||
|
name={`${serie.id}:dashed:noTooltip`}
|
||||||
|
isAnimationActive={false}
|
||||||
|
strokeWidth={2}
|
||||||
|
dataKey={`${serie.id}:count`}
|
||||||
|
stroke={`url('#hideAllButLastInterval_${serie.id}')`}
|
||||||
|
strokeDasharray="4 2"
|
||||||
|
strokeOpacity={0.7}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
dot={false}
|
||||||
|
type={lineType}
|
||||||
|
name={`${serie.id}:solid:noTooltip`}
|
||||||
|
isAnimationActive={false}
|
||||||
|
strokeWidth={2}
|
||||||
|
dataKey={`${serie.id}:count`}
|
||||||
|
stroke={`url('#hideJustLastInterval_${serie.id}')`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{previous && (
|
||||||
|
<Line
|
||||||
|
type={lineType}
|
||||||
|
name={`${serie.id}:prev`}
|
||||||
|
isAnimationActive
|
||||||
|
dot={false}
|
||||||
|
strokeOpacity={0.3}
|
||||||
|
dataKey={`${serie.id}:prev:count`}
|
||||||
|
stroke={color}
|
||||||
|
// Use for legend
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
{isEditMode && (
|
||||||
|
<ReportTable
|
||||||
|
data={data}
|
||||||
|
visibleSeries={series}
|
||||||
|
setVisibleSeries={setVisibleSeries}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
apps/dashboard/src/components/report-chart/line/index.tsx
Normal file
59
apps/dashboard/src/components/report-chart/line/index.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { api } from '@/trpc/client';
|
||||||
|
|
||||||
|
import { AspectContainer } from '../aspect-container';
|
||||||
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
|
import { ReportChartError } from '../common/error';
|
||||||
|
import { ReportChartLoading } from '../common/loading';
|
||||||
|
import { useReportChartContext } from '../context';
|
||||||
|
import { Chart } from './chart';
|
||||||
|
|
||||||
|
export function ReportLineChart() {
|
||||||
|
const { isLazyLoading, report } = useReportChartContext();
|
||||||
|
|
||||||
|
const res = api.chart.chart.useQuery(report, {
|
||||||
|
keepPreviousData: true,
|
||||||
|
staleTime: 1000 * 60 * 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLazyLoading || res.isLoading || res.isFetching) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.isError) {
|
||||||
|
return <Error />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.data.series.length === 0) {
|
||||||
|
return <Empty />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AspectContainer>
|
||||||
|
<Chart data={res.data} />
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Loading() {
|
||||||
|
return (
|
||||||
|
<AspectContainer>
|
||||||
|
<ReportChartLoading />
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Error() {
|
||||||
|
return (
|
||||||
|
<AspectContainer>
|
||||||
|
<ReportChartError />
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Empty() {
|
||||||
|
return (
|
||||||
|
<AspectContainer>
|
||||||
|
<ReportChartEmpty />
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,14 +5,16 @@ import { theme } from '@/utils/theme';
|
|||||||
import WorldMap from 'react-svg-worldmap';
|
import WorldMap from 'react-svg-worldmap';
|
||||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||||
|
|
||||||
import { useChartContext } from './ChartProvider';
|
import { useReportChartContext } from '../context';
|
||||||
|
|
||||||
interface ReportMapChartProps {
|
interface Props {
|
||||||
data: IChartData;
|
data: IChartData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReportMapChart({ data }: ReportMapChartProps) {
|
export function Chart({ data }: Props) {
|
||||||
const { metric, unit } = useChartContext();
|
const {
|
||||||
|
report: { metric, unit },
|
||||||
|
} = useReportChartContext();
|
||||||
const { series } = useVisibleSeries(data, 100);
|
const { series } = useVisibleSeries(data, 100);
|
||||||
|
|
||||||
const mapData = useMemo(
|
const mapData = useMemo(
|
||||||
55
apps/dashboard/src/components/report-chart/map/index.tsx
Normal file
55
apps/dashboard/src/components/report-chart/map/index.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { api } from '@/trpc/client';
|
||||||
|
|
||||||
|
import { AspectContainer } from '../aspect-container';
|
||||||
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
|
import { ReportChartError } from '../common/error';
|
||||||
|
import { ReportChartLoading } from '../common/loading';
|
||||||
|
import { useReportChartContext } from '../context';
|
||||||
|
import { Chart } from './chart';
|
||||||
|
|
||||||
|
export function ReportMapChart() {
|
||||||
|
const { isLazyLoading, report } = useReportChartContext();
|
||||||
|
|
||||||
|
const res = api.chart.chart.useQuery(report, {
|
||||||
|
keepPreviousData: true,
|
||||||
|
staleTime: 1000 * 60 * 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLazyLoading || res.isLoading || res.isFetching) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.isError) {
|
||||||
|
return <Error />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.data.series.length === 0) {
|
||||||
|
return <Empty />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Chart data={res.data} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Loading() {
|
||||||
|
return (
|
||||||
|
<AspectContainer>
|
||||||
|
<ReportChartLoading />
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Error() {
|
||||||
|
return (
|
||||||
|
<AspectContainer>
|
||||||
|
<ReportChartError />
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Empty() {
|
||||||
|
return (
|
||||||
|
<AspectContainer>
|
||||||
|
<ReportChartEmpty />
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,21 +4,24 @@ import { useVisibleSeries } from '@/hooks/useVisibleSeries';
|
|||||||
import type { IChartData } from '@/trpc/client';
|
import type { IChartData } from '@/trpc/client';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
import { useChartContext } from './ChartProvider';
|
import { useReportChartContext } from '../context';
|
||||||
import { MetricCard } from './MetricCard';
|
import { MetricCard } from './metric-card';
|
||||||
|
|
||||||
interface ReportMetricChartProps {
|
interface Props {
|
||||||
data: IChartData;
|
data: IChartData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReportMetricChart({ data }: ReportMetricChartProps) {
|
export function Chart({ data }: Props) {
|
||||||
const { editMode, metric, unit } = useChartContext();
|
const {
|
||||||
const { series } = useVisibleSeries(data, editMode ? 20 : 4);
|
isEditMode,
|
||||||
|
report: { metric, unit },
|
||||||
|
} = useReportChartContext();
|
||||||
|
const { series } = useVisibleSeries(data, isEditMode ? 20 : 4);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'grid grid-cols-1 gap-4',
|
'grid grid-cols-1 gap-4',
|
||||||
editMode && 'md:grid-cols-2 lg:grid-cols-3'
|
isEditMode && 'md:grid-cols-2 lg:grid-cols-3'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{series.map((serie) => {
|
{series.map((serie) => {
|
||||||
65
apps/dashboard/src/components/report-chart/metric/index.tsx
Normal file
65
apps/dashboard/src/components/report-chart/metric/index.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { api } from '@/trpc/client';
|
||||||
|
|
||||||
|
import { useReportChartContext } from '../context';
|
||||||
|
import { Chart } from './chart';
|
||||||
|
|
||||||
|
export function ReportMetricChart() {
|
||||||
|
const { isLazyLoading, report } = useReportChartContext();
|
||||||
|
|
||||||
|
const res = api.chart.chart.useQuery(report, {
|
||||||
|
keepPreviousData: true,
|
||||||
|
staleTime: 1000 * 60 * 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLazyLoading || res.isLoading || res.isFetching) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.isError) {
|
||||||
|
return <Error />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.data.series.length === 0) {
|
||||||
|
return <Empty />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Chart data={res.data} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[78px] flex-col justify-between p-4">
|
||||||
|
<div className="h-3 w-1/2 animate-pulse rounded bg-def-200"></div>
|
||||||
|
<div className="row items-end justify-between">
|
||||||
|
<div className="h-6 w-1/3 animate-pulse rounded bg-def-200"></div>
|
||||||
|
<div className="h-3 w-1/5 animate-pulse rounded bg-def-200"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Error() {
|
||||||
|
return (
|
||||||
|
<div className="relative h-[70px]">
|
||||||
|
<div className="opacity-50">
|
||||||
|
<Loading />
|
||||||
|
</div>
|
||||||
|
<div className="center-center absolute inset-0 text-muted-foreground">
|
||||||
|
<div className="text-sm font-medium">Error fetching data</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Empty() {
|
||||||
|
return (
|
||||||
|
<div className="relative h-[70px]">
|
||||||
|
<div className="opacity-50">
|
||||||
|
<Loading />
|
||||||
|
</div>
|
||||||
|
<div className="center-center absolute inset-0 text-muted-foreground">
|
||||||
|
<div className="text-sm font-medium">No data</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,9 +11,9 @@ import type { IChartMetric } from '@openpanel/validation';
|
|||||||
import {
|
import {
|
||||||
getDiffIndicator,
|
getDiffIndicator,
|
||||||
PreviousDiffIndicator,
|
PreviousDiffIndicator,
|
||||||
} from '../PreviousDiffIndicator';
|
} from '../common/previous-diff-indicator';
|
||||||
import { useChartContext } from './ChartProvider';
|
import { SerieName } from '../common/serie-name';
|
||||||
import { SerieName } from './SerieName';
|
import { useReportChartContext } from '../context';
|
||||||
|
|
||||||
interface MetricCardProps {
|
interface MetricCardProps {
|
||||||
serie: IChartData['series'][number];
|
serie: IChartData['series'][number];
|
||||||
@@ -28,7 +28,10 @@ export function MetricCard({
|
|||||||
metric,
|
metric,
|
||||||
unit,
|
unit,
|
||||||
}: MetricCardProps) {
|
}: MetricCardProps) {
|
||||||
const { previousIndicatorInverted, editMode } = useChartContext();
|
const {
|
||||||
|
report: { previousIndicatorInverted },
|
||||||
|
isEditMode,
|
||||||
|
} = useReportChartContext();
|
||||||
const number = useNumber();
|
const number = useNumber();
|
||||||
|
|
||||||
const renderValue = (value: number, unitClassName?: string) => {
|
const renderValue = (value: number, unitClassName?: string) => {
|
||||||
@@ -56,33 +59,45 @@ export function MetricCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn('group relative p-4', isEditMode && 'card h-auto')}
|
||||||
'group relative h-[70px] overflow-hidden',
|
|
||||||
editMode && 'card h-[100px] px-4 py-2'
|
|
||||||
)}
|
|
||||||
key={serie.id}
|
key={serie.id}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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',
|
'pointer-events-none absolute -left-1 -right-1 bottom-0 top-0 z-0 opacity-50 transition-opacity duration-300 group-hover:opacity-100'
|
||||||
editMode && 'bottom-1'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<AutoSizer>
|
<AutoSizer>
|
||||||
{({ width, height }) => (
|
{({ width, height }) => (
|
||||||
<AreaChart
|
<AreaChart
|
||||||
width={width}
|
width={width}
|
||||||
height={height / 3}
|
height={height / 4}
|
||||||
data={serie.data}
|
data={serie.data}
|
||||||
style={{ marginTop: (height / 3) * 2 }}
|
style={{ marginTop: (height / 4) * 3 }}
|
||||||
>
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id={`colorUv${serie.id}`}
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="0"
|
||||||
|
y2="1"
|
||||||
|
>
|
||||||
|
<stop offset="0%" stopColor={graphColors} stopOpacity={0.2} />
|
||||||
|
<stop
|
||||||
|
offset="100%"
|
||||||
|
stopColor={graphColors}
|
||||||
|
stopOpacity={0.05}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
<Area
|
<Area
|
||||||
dataKey="count"
|
dataKey="count"
|
||||||
type="monotone"
|
type="step"
|
||||||
fill={`transparent`}
|
fill={`url(#colorUv${serie.id})`}
|
||||||
fillOpacity={1}
|
fillOpacity={1}
|
||||||
stroke={graphColors}
|
stroke={graphColors}
|
||||||
strokeWidth={2}
|
strokeWidth={1}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
@@ -98,7 +113,7 @@ export function MetricCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end justify-between">
|
<div className="flex items-end justify-between">
|
||||||
<div className="font-mono truncate text-3xl font-bold">
|
<div className="truncate font-mono text-3xl font-bold">
|
||||||
{renderValue(serie.metrics[metric], 'ml-1 font-light text-xl')}
|
{renderValue(serie.metrics[metric], 'ml-1 font-light text-xl')}
|
||||||
</div>
|
</div>
|
||||||
<PreviousDiffIndicator
|
<PreviousDiffIndicator
|
||||||
@@ -110,23 +125,3 @@ export function MetricCard({
|
|||||||
</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,22 +1,22 @@
|
|||||||
import { AutoSizer } from '@/components/react-virtualized-auto-sizer';
|
|
||||||
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
|
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
|
||||||
import type { IChartData } from '@/trpc/client';
|
import type { IChartData } from '@/trpc/client';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { round } from '@/utils/math';
|
import { round } from '@/utils/math';
|
||||||
import { getChartColor } from '@/utils/theme';
|
import { getChartColor } from '@/utils/theme';
|
||||||
import { truncate } from '@/utils/truncate';
|
import { truncate } from '@/utils/truncate';
|
||||||
import { Cell, Pie, PieChart, Tooltip } from 'recharts';
|
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
|
||||||
|
|
||||||
import { useChartContext } from './ChartProvider';
|
import { AXIS_FONT_PROPS } from '../common/axis';
|
||||||
import { ReportChartTooltip } from './ReportChartTooltip';
|
import { ReportChartTooltip } from '../common/report-chart-tooltip';
|
||||||
import { ReportTable } from './ReportTable';
|
import { ReportTable } from '../common/report-table';
|
||||||
|
import { useReportChartContext } from '../context';
|
||||||
|
|
||||||
interface ReportPieChartProps {
|
interface Props {
|
||||||
data: IChartData;
|
data: IChartData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReportPieChart({ data }: ReportPieChartProps) {
|
export function Chart({ data }: Props) {
|
||||||
const { editMode } = useChartContext();
|
const { isEditMode } = useReportChartContext();
|
||||||
const { series, setVisibleSeries } = useVisibleSeries(data);
|
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||||
|
|
||||||
const sum = series.reduce((acc, serie) => acc + serie.metrics.sum, 0);
|
const sum = series.reduce((acc, serie) => acc + serie.metrics.sum, 0);
|
||||||
@@ -31,18 +31,17 @@ export function ReportPieChart({ data }: ReportPieChartProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={cn('max-sm:-mx-3', editMode && 'card p-4')}>
|
<div
|
||||||
<AutoSizer disableHeight>
|
className={cn('h-full w-full max-sm:-mx-3', isEditMode && 'card p-4')}
|
||||||
{({ width }) => {
|
>
|
||||||
const height = Math.min(Math.max(width * 0.5625, 250), 400);
|
<ResponsiveContainer>
|
||||||
return (
|
<PieChart>
|
||||||
<PieChart width={width} height={height}>
|
|
||||||
<Tooltip content={<ReportChartTooltip />} />
|
<Tooltip content={<ReportChartTooltip />} />
|
||||||
<Pie
|
<Pie
|
||||||
dataKey={'count'}
|
dataKey={'count'}
|
||||||
data={pieData}
|
data={pieData}
|
||||||
innerRadius={height / 4}
|
innerRadius={'50%'}
|
||||||
outerRadius={height / 2.5}
|
outerRadius={'80%'}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
label={renderLabel}
|
label={renderLabel}
|
||||||
>
|
>
|
||||||
@@ -58,11 +57,9 @@ export function ReportPieChart({ data }: ReportPieChartProps) {
|
|||||||
})}
|
})}
|
||||||
</Pie>
|
</Pie>
|
||||||
</PieChart>
|
</PieChart>
|
||||||
);
|
</ResponsiveContainer>
|
||||||
}}
|
|
||||||
</AutoSizer>
|
|
||||||
</div>
|
</div>
|
||||||
{editMode && (
|
{isEditMode && (
|
||||||
<ReportTable
|
<ReportTable
|
||||||
data={data}
|
data={data}
|
||||||
visibleSeries={series}
|
visibleSeries={series}
|
||||||
@@ -108,9 +105,9 @@ const renderLabel = ({
|
|||||||
fill="white"
|
fill="white"
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
dominantBaseline="central"
|
dominantBaseline="central"
|
||||||
fontSize={12}
|
|
||||||
fontWeight={700}
|
fontWeight={700}
|
||||||
pointerEvents={'none'}
|
pointerEvents={'none'}
|
||||||
|
{...AXIS_FONT_PROPS}
|
||||||
>
|
>
|
||||||
{percent}%
|
{percent}%
|
||||||
</text>
|
</text>
|
||||||
@@ -120,7 +117,7 @@ const renderLabel = ({
|
|||||||
fill={fill}
|
fill={fill}
|
||||||
textAnchor={x > cx ? 'start' : 'end'}
|
textAnchor={x > cx ? 'start' : 'end'}
|
||||||
dominantBaseline="central"
|
dominantBaseline="central"
|
||||||
fontSize={12}
|
{...AXIS_FONT_PROPS}
|
||||||
>
|
>
|
||||||
{truncate(name, 20)}
|
{truncate(name, 20)}
|
||||||
</text>
|
</text>
|
||||||
59
apps/dashboard/src/components/report-chart/pie/index.tsx
Normal file
59
apps/dashboard/src/components/report-chart/pie/index.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { api } from '@/trpc/client';
|
||||||
|
|
||||||
|
import { AspectContainer } from '../aspect-container';
|
||||||
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
|
import { ReportChartError } from '../common/error';
|
||||||
|
import { ReportChartLoading } from '../common/loading';
|
||||||
|
import { useReportChartContext } from '../context';
|
||||||
|
import { Chart } from './chart';
|
||||||
|
|
||||||
|
export function ReportPieChart() {
|
||||||
|
const { isLazyLoading, report } = useReportChartContext();
|
||||||
|
|
||||||
|
const res = api.chart.chart.useQuery(report, {
|
||||||
|
keepPreviousData: true,
|
||||||
|
staleTime: 1000 * 60 * 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLazyLoading || res.isLoading || res.isFetching) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.isError) {
|
||||||
|
return <Error />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.data.series.length === 0) {
|
||||||
|
return <Empty />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AspectContainer>
|
||||||
|
<Chart data={res.data} />
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Loading() {
|
||||||
|
return (
|
||||||
|
<AspectContainer>
|
||||||
|
<ReportChartLoading />
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Error() {
|
||||||
|
return (
|
||||||
|
<AspectContainer>
|
||||||
|
<ReportChartError />
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Empty() {
|
||||||
|
return (
|
||||||
|
<AspectContainer>
|
||||||
|
<ReportChartEmpty />
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
apps/dashboard/src/components/report-chart/shortcut.tsx
Normal file
43
apps/dashboard/src/components/report-chart/shortcut.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { ReportChart } from '.';
|
||||||
|
import type { ReportChartProps } from './context';
|
||||||
|
|
||||||
|
type ChartRootShortcutProps = Omit<ReportChartProps, 'report'> & {
|
||||||
|
projectId: ReportChartProps['report']['projectId'];
|
||||||
|
range?: ReportChartProps['report']['range'];
|
||||||
|
previous?: ReportChartProps['report']['previous'];
|
||||||
|
chartType?: ReportChartProps['report']['chartType'];
|
||||||
|
interval?: ReportChartProps['report']['interval'];
|
||||||
|
events: ReportChartProps['report']['events'];
|
||||||
|
breakdowns?: ReportChartProps['report']['breakdowns'];
|
||||||
|
lineType?: ReportChartProps['report']['lineType'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ReportChartShortcut = ({
|
||||||
|
projectId,
|
||||||
|
range = '7d',
|
||||||
|
previous = false,
|
||||||
|
chartType = 'linear',
|
||||||
|
interval = 'day',
|
||||||
|
events,
|
||||||
|
breakdowns,
|
||||||
|
lineType = 'monotone',
|
||||||
|
options,
|
||||||
|
}: ChartRootShortcutProps) => {
|
||||||
|
return (
|
||||||
|
<ReportChart
|
||||||
|
report={{
|
||||||
|
name: 'Shortcut',
|
||||||
|
projectId,
|
||||||
|
range,
|
||||||
|
breakdowns: breakdowns ?? [],
|
||||||
|
previous,
|
||||||
|
chartType,
|
||||||
|
interval,
|
||||||
|
events,
|
||||||
|
lineType,
|
||||||
|
metric: 'sum',
|
||||||
|
}}
|
||||||
|
options={options ?? {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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,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,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,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,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,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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
30
apps/dashboard/src/components/stats.tsx
Normal file
30
apps/dashboard/src/components/stats.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
|
export function Stats({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="@container">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'grid overflow-hidden rounded border bg-background @xl:grid-cols-3 @4xl:grid-cols-6',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatsCard({ title, value }: { title: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="col gap-2 p-4 ring-[0.5px] ring-border">
|
||||||
|
<div className="text-muted-foreground">{title}</div>
|
||||||
|
<div className="truncate font-mono text-2xl font-bold">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -82,15 +82,26 @@ SheetContent.displayName = SheetPrimitive.Content.displayName;
|
|||||||
|
|
||||||
const SheetHeader = ({
|
const SheetHeader = ({
|
||||||
className,
|
className,
|
||||||
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-col space-y-2 text-center sm:text-left',
|
'relative -m-6 mb-6 flex justify-between rounded-t-lg border-b bg-def-100 p-6',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
style={{
|
||||||
|
backgroundImage: `url("data:image/svg+xml,<svg id='patternId' width='100%' height='100%' xmlns='http://www.w3.org/2000/svg'><defs><pattern id='a' patternUnits='userSpaceOnUse' width='20' height='20' patternTransform='scale(2) rotate(85)'><rect x='0' y='0' width='100%' height='100%' fill='hsla(0, 0%, 100%, 0)'/><path d='M 10,-2.55e-7 V 20 Z M -1.1677362e-8,10 H 20 Z' stroke-width='0.5' stroke='hsla(259, 0%, 52%, 0.46)' fill='none'/></pattern></defs><rect width='800%' height='800%' transform='translate(0,0)' fill='url(%23a)'/></svg>")`,
|
||||||
|
backgroundSize: '100% 100%',
|
||||||
|
backgroundRepeat: 'repeat',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-def-100/95 to-def-100/80"></div>
|
||||||
|
<div className="row relative w-full items-start justify-between">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
SheetHeader.displayName = 'SheetHeader';
|
SheetHeader.displayName = 'SheetHeader';
|
||||||
|
|
||||||
@@ -114,7 +125,7 @@ const SheetTitle = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SheetPrimitive.Title
|
<SheetPrimitive.Title
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn('text-lg font-semibold text-foreground', className)}
|
className={cn('text-3xl font-bold text-foreground', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import { ChartLoading } from '@/components/report/chart/ChartLoading';
|
|
||||||
import { Widget, WidgetHead } from '@/components/widget';
|
import { Widget, WidgetHead } from '@/components/widget';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
@@ -21,7 +20,7 @@ const withLoadingWidget = <P,>(Component: React.ComponentType<P>) => {
|
|||||||
<WidgetHead>
|
<WidgetHead>
|
||||||
<span className="title">Loading...</span>
|
<span className="title">Loading...</span>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<ChartLoading />
|
<div className="aspect-video animate-pulse rounded bg-def-100" />
|
||||||
</Widget>
|
</Widget>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ChartRoot } from '@/components/report/chart';
|
import { ReportChart } from '@/components/report-chart';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
|
||||||
import type { IChartProps } from '@openpanel/validation';
|
import type { IChartProps } from '@openpanel/validation';
|
||||||
@@ -15,7 +15,13 @@ const OverviewChartDetails = (props: Props) => {
|
|||||||
<ModalHeader title={props.chart.name} />
|
<ModalHeader title={props.chart.name} />
|
||||||
<ScrollArea className="-m-6 max-h-[calc(100vh-200px)]">
|
<ScrollArea className="-m-6 max-h-[calc(100vh-200px)]">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<ChartRoot {...props.chart} limit={999} chartType="bar" />
|
<ReportChart
|
||||||
|
report={{
|
||||||
|
...props.chart,
|
||||||
|
limit: 999,
|
||||||
|
chartType: 'bar',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ChartRootShortcut } from '@/components/report/chart';
|
import { ReportChartShortcut } from '@/components/report-chart/shortcut';
|
||||||
import { KeyValue } from '@/components/ui/key-value';
|
import { KeyValue } from '@/components/ui/key-value';
|
||||||
import { useAppParams } from '@/hooks/useAppParams';
|
import { useAppParams } from '@/hooks/useAppParams';
|
||||||
import {
|
import {
|
||||||
@@ -193,7 +193,7 @@ export default function EventDetails({ id }: Props) {
|
|||||||
Show all
|
Show all
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<ChartRootShortcut
|
<ReportChartShortcut
|
||||||
projectId={event.projectId}
|
projectId={event.projectId}
|
||||||
chartType="histogram"
|
chartType="histogram"
|
||||||
events={[
|
events={[
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--highlight: 220, 84%, 53%; /* #2266ec */
|
--highlight: 220 84% 53%; /* #2266ec */
|
||||||
|
|
||||||
--def-100: 210 40% 98%; /* #F0F4F9 */
|
--def-100: 210 40% 98%; /* #F0F4F9 */
|
||||||
--def-200: 210 40% 96.1%; /* #E7ECF2 */
|
--def-200: 210 40% 96.1%; /* #E7ECF2 */
|
||||||
|
|||||||
Reference in New Issue
Block a user