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

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

View File

@@ -1,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,
getDefaultIntervalByDates(startDate, endDate) || endDate: endDate ?? report.endDate,
(range ? getDefaultIntervalByRange(range) : report.interval) interval:
} getDefaultIntervalByDates(startDate, endDate) ||
editMode={false} (range
? getDefaultIntervalByRange(range)
: report.interval),
}}
/> />
</div> </div>
</div> </div>

View File

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

View File

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

View File

@@ -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,41 +71,46 @@ 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,
{
id: 'A', chartType: 'linear',
name: 'screen_view', projectId: item.project_id,
segment: 'event', events: [
filters: [ {
{ id: 'A',
id: 'path', name: 'screen_view',
name: 'path', segment: 'event',
value: [item.path], filters: [
operator: 'is', {
}, id: 'path',
{ name: 'path',
id: 'origin', value: [item.path],
name: 'origin', operator: 'is',
value: [item.origin], },
operator: 'is', {
}, id: 'origin',
], name: 'origin',
}, value: [item.origin],
]} operator: 'is',
},
],
},
],
}}
/> />
</div> </div>
</div> </div>

View File

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

View File

@@ -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>
); );
} }
@@ -40,7 +40,7 @@ const ProfileMetrics = ({ data, profile }: Props) => {
const number = useNumber(); const number = useNumber();
return ( return (
<div className="@container"> <div className="@container">
<div className="grid grid-cols-2 overflow-hidden whitespace-nowrap rounded-md border bg-background @xl:grid-cols-3 @4xl:grid-cols-6"> <div className="grid grid-cols-2 overflow-hidden whitespace-nowrap rounded-md border bg-background @xl:grid-cols-3 @4xl:grid-cols-6">
<div className="col-span-2 @xl:col-span-3 @4xl:col-span-6"> <div className="col-span-2 @xl:col-span-3 @4xl:col-span-6">
<div className="row border-b"> <div className="row border-b">
<button <button

View File

@@ -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: [
{ {

View File

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

View File

@@ -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,69 +41,62 @@ 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 offset="0%"
offset="0%" stopColor={getChartColor(0)}
stopColor={getChartColor(0)} stopOpacity={0.8}
stopOpacity={0.8} ></stop>
></stop> <stop
<stop offset="100%"
offset="100%" stopColor={getChartColor(0)}
stopColor={getChartColor(0)} stopOpacity={0.1}
stopOpacity={0.1} ></stop>
></stop> </linearGradient>
</linearGradient> </defs>
</defs>
<RechartTooltip content={<Tooltip />} /> <RechartTooltip content={<Tooltip />} />
<Area <Area
dataKey="users" dataKey="users"
stroke={getChartColor(0)} stroke={getChartColor(0)}
strokeWidth={2} strokeWidth={2}
fill={`url(#bg)`} fill={`url(#bg)`}
isAnimationActive={false} isAnimationActive={false}
/> />
<XAxis <XAxis
dataKey="days" {...xAxisProps}
axisLine={false} dataKey="days"
fontSize={12} scale="auto"
// type="number" type="category"
tickLine={false} label={{
label={{ value: 'DAYS',
value: 'DAYS', position: 'insideBottom',
position: 'insideBottom', offset: 0,
offset: 0, fontSize: 10,
fontSize: 10, }}
}} />
/> <YAxis
<YAxis {...yAxisProps}
label={{ label={{
value: 'USERS', value: 'USERS',
angle: -90, angle: -90,
position: 'insideLeft', position: 'insideLeft',
offset: 0, offset: 0,
fontSize: 10, fontSize: 10,
}} }}
dataKey="users" dataKey="users"
fontSize={12} />
axisLine={false} </AreaChart>
tickLine={false}
width={getYAxisWidth(max)}
allowDecimals={false}
domain={[0, max]}
tickFormatter={number.short}
/>
</AreaChart>
)}
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
); );

View File

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

View File

@@ -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,106 +52,94 @@ 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 offset="0%"
offset="0%" stopColor={getChartColor(0)}
stopColor={getChartColor(0)} stopOpacity={0.8}
stopOpacity={0.8} ></stop>
></stop> <stop
<stop offset="100%"
offset="100%" stopColor={getChartColor(0)}
stopColor={getChartColor(0)} stopOpacity={0.1}
stopOpacity={0.1} ></stop>
></stop> </linearGradient>
</linearGradient> <linearGradient id="wau" x1="0" y1="0" x2="0" y2="1">
<linearGradient id="wau" x1="0" y1="0" x2="0" y2="1"> <stop
<stop offset="0%"
offset="0%" stopColor={getChartColor(1)}
stopColor={getChartColor(1)} stopOpacity={0.8}
stopOpacity={0.8} ></stop>
></stop> <stop
<stop offset="100%"
offset="100%" stopColor={getChartColor(1)}
stopColor={getChartColor(1)} stopOpacity={0.1}
stopOpacity={0.1} ></stop>
></stop> </linearGradient>
</linearGradient> <linearGradient id="mau" x1="0" y1="0" x2="0" y2="1">
<linearGradient id="mau" x1="0" y1="0" x2="0" y2="1"> <stop
<stop offset="0%"
offset="0%" stopColor={getChartColor(2)}
stopColor={getChartColor(2)} stopOpacity={0.8}
stopOpacity={0.8} ></stop>
></stop> <stop
<stop offset="100%"
offset="100%" stopColor={getChartColor(2)}
stopColor={getChartColor(2)} stopOpacity={0.1}
stopOpacity={0.1} ></stop>
></stop> </linearGradient>
</linearGradient> </defs>
</defs>
<RechartTooltip content={<Tooltip />} /> <RechartTooltip content={<Tooltip />} />
<Area <Area
dataKey="dau" dataKey="dau"
stroke={getChartColor(0)} stroke={getChartColor(0)}
strokeWidth={2} strokeWidth={2}
fill={`url(#dau)`} fill={`url(#dau)`}
isAnimationActive={false} isAnimationActive={false}
/> />
<Area <Area
dataKey="wau" dataKey="wau"
stroke={getChartColor(1)} stroke={getChartColor(1)}
strokeWidth={2} strokeWidth={2}
fill={`url(#wau)`} fill={`url(#wau)`}
isAnimationActive={false} isAnimationActive={false}
/> />
<Area <Area
dataKey="mau" dataKey="mau"
stroke={getChartColor(2)} stroke={getChartColor(2)}
strokeWidth={2} strokeWidth={2}
fill={`url(#mau)`} fill={`url(#mau)`}
isAnimationActive={false} isAnimationActive={false}
/> />
<XAxis <XAxis {...xAxisProps} dataKey="date" />
dataKey="date" <YAxis
axisLine={false} {...yAxisProps}
fontSize={12} label={{
// type="number" value: 'UNIQUE USERS',
tickLine={false} angle: -90,
/> position: 'insideLeft',
<YAxis offset: 0,
label={{ fontSize: 10,
value: 'UNIQUE USERS', }}
angle: -90, />
position: 'insideLeft', </AreaChart>
offset: 0,
fontSize: 10,
}}
fontSize={12}
axisLine={false}
tickLine={false}
width={getYAxisWidth(max)}
allowDecimals={false}
domain={[0, max]}
tickFormatter={number.short}
/>
</AreaChart>
)}
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
); );

View File

@@ -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,69 +56,61 @@ 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 offset="0%"
offset="0%" stopColor={getChartColor(0)}
stopColor={getChartColor(0)} stopOpacity={0.8}
stopOpacity={0.8} ></stop>
></stop> <stop
<stop offset="100%"
offset="100%" stopColor={getChartColor(0)}
stopColor={getChartColor(0)} stopOpacity={0.1}
stopOpacity={0.1} ></stop>
></stop> </linearGradient>
</linearGradient> </defs>
</defs>
<RechartTooltip content={<Tooltip />} /> <RechartTooltip content={<Tooltip />} />
<Area <Area
dataKey="retention" dataKey="retention"
stroke={getChartColor(0)} stroke={getChartColor(0)}
strokeWidth={2} strokeWidth={2}
fill={`url(#bg)`} fill={`url(#bg)`}
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))} allowDuplicatedCategory={false}
tickLine={false} label={{
allowDuplicatedCategory={false} value: 'DATE',
label={{ position: 'insideBottom',
value: 'DATE', offset: 0,
position: 'insideBottom', fontSize: 10,
offset: 0, }}
fontSize: 10, />
}} <YAxis
/> {...yAxisProps}
<YAxis label={{
label={{ value: 'RETENTION (%)',
value: 'RETENTION (%)', angle: -90,
angle: -90, position: 'insideLeft',
position: 'insideLeft', offset: 0,
offset: 0, fontSize: 10,
fontSize: 10, }}
}} />
fontSize={12} </AreaChart>
axisLine={false}
tickLine={false}
width={getYAxisWidth(max)}
allowDecimals={false}
domain={[0, max]}
tickFormatter={number.short}
/>
</AreaChart>
)}
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
); );

View File

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

View File

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

View File

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

View File

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

View File

@@ -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={{

View File

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

View File

@@ -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,238 +31,258 @@ export default function OverviewTopDevices({
title: 'Top devices', title: 'Top devices',
btn: 'Devices', btn: 'Devices',
chart: { chart: {
limit: 10, report: {
projectId, limit: 10,
startDate, projectId,
endDate, startDate,
events: [ endDate,
{ events: [
segment: 'user', {
filters, segment: 'user',
id: 'A', filters,
name: isPageFilter ? 'screen_view' : 'session_start', id: 'A',
}, name: isPageFilter ? 'screen_view' : 'session_start',
], },
breakdowns: [ ],
{ breakdowns: [
id: 'A', {
name: 'device', id: 'A',
}, name: 'device',
], },
chartType, ],
lineType: 'monotone', chartType,
interval: interval, lineType: 'monotone',
name: 'Top devices', interval: interval,
range: range, name: 'Top devices',
previous: previous, range: range,
metric: 'sum', previous: previous,
metric: 'sum',
},
}, },
}, },
browser: { browser: {
title: 'Top browser', title: 'Top browser',
btn: 'Browser', btn: 'Browser',
chart: { chart: {
limit: 10, report: {
projectId, limit: 10,
startDate, projectId,
endDate, startDate,
events: [ endDate,
{ events: [
segment: 'user', {
filters, segment: 'user',
id: 'A', filters,
name: isPageFilter ? 'screen_view' : 'session_start', id: 'A',
}, name: isPageFilter ? 'screen_view' : 'session_start',
], },
breakdowns: [ ],
{ breakdowns: [
id: 'A', {
name: 'browser', id: 'A',
}, name: 'browser',
], },
chartType, ],
lineType: 'monotone', chartType,
interval: interval, lineType: 'monotone',
name: 'Top browser', interval: interval,
range: range, name: 'Top browser',
previous: previous, range: range,
metric: 'sum', previous: previous,
metric: 'sum',
},
}, },
}, },
browser_version: { browser_version: {
title: 'Top Browser Version', title: 'Top Browser Version',
btn: 'Browser Version', btn: 'Browser Version',
chart: { chart: {
renderSerieName(name) { options: {
return name[1] || NOT_SET_VALUE; renderSerieName(name) {
return name[1] || NOT_SET_VALUE;
},
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'browser',
},
{
id: 'B',
name: 'browser_version',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top Browser Version',
range: range,
previous: previous,
metric: 'sum',
}, },
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'browser',
},
{
id: 'B',
name: 'browser_version',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top Browser Version',
range: range,
previous: previous,
metric: 'sum',
}, },
}, },
os: { os: {
title: 'Top OS', title: 'Top OS',
btn: 'OS', btn: 'OS',
chart: { chart: {
limit: 10, report: {
projectId, limit: 10,
startDate, projectId,
endDate, startDate,
events: [ endDate,
{ events: [
segment: 'user', {
filters, segment: 'user',
id: 'A', filters,
name: isPageFilter ? 'screen_view' : 'session_start', id: 'A',
}, name: isPageFilter ? 'screen_view' : 'session_start',
], },
breakdowns: [ ],
{ breakdowns: [
id: 'A', {
name: 'os', id: 'A',
}, name: 'os',
], },
chartType, ],
lineType: 'monotone', chartType,
interval: interval, lineType: 'monotone',
name: 'Top OS', interval: interval,
range: range, name: 'Top OS',
previous: previous, range: range,
metric: 'sum', previous: previous,
metric: 'sum',
},
}, },
}, },
os_version: { os_version: {
title: 'Top OS version', title: 'Top OS version',
btn: 'OS Version', btn: 'OS Version',
chart: { chart: {
renderSerieName(name) { options: {
return name[1] || NOT_SET_VALUE; renderSerieName(name) {
return name[1] || NOT_SET_VALUE;
},
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'os',
},
{
id: 'B',
name: 'os_version',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top OS version',
range: range,
previous: previous,
metric: 'sum',
}, },
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'os',
},
{
id: 'B',
name: 'os_version',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top OS version',
range: range,
previous: previous,
metric: 'sum',
}, },
}, },
brands: { brands: {
title: 'Top Brands', title: 'Top Brands',
btn: 'Brands', btn: 'Brands',
chart: { chart: {
limit: 10, report: {
projectId, limit: 10,
startDate, projectId,
endDate, startDate,
events: [ endDate,
{ events: [
segment: 'user', {
filters, segment: 'user',
id: 'A', filters,
name: isPageFilter ? 'screen_view' : 'session_start', id: 'A',
}, name: isPageFilter ? 'screen_view' : 'session_start',
], },
breakdowns: [ ],
{ breakdowns: [
id: 'A', {
name: 'brand', id: 'A',
}, name: 'brand',
], },
chartType, ],
lineType: 'monotone', chartType,
interval: interval, lineType: 'monotone',
name: 'Top Brands', interval: interval,
range: range, name: 'Top Brands',
previous: previous, range: range,
metric: 'sum', previous: previous,
metric: 'sum',
},
}, },
}, },
models: { models: {
title: 'Top Models', title: 'Top Models',
btn: 'Models', btn: 'Models',
chart: { chart: {
renderSerieName(name) { options: {
return name[1] || NOT_SET_VALUE; renderSerieName(name) {
return name[1] || NOT_SET_VALUE;
},
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'brand',
},
{
id: 'B',
name: 'model',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top Models',
range: range,
previous: previous,
metric: 'sum',
}, },
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'brand',
},
{
id: 'B',
name: 'model',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top Models',
range: range,
previous: previous,
metric: 'sum',
}, },
}, },
}); });
@@ -285,33 +305,38 @@ 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]);
break; break;
case 'browser': case 'browser':
setFilter('browser', item.names[0]); setFilter('browser', item.names[0]);
break; break;
case 'browser_version': case 'browser_version':
setFilter('browser_version', item.names[1]); setFilter('browser_version', item.names[1]);
break; break;
case 'os': case 'os':
setFilter('os', item.names[0]); setFilter('os', item.names[0]);
break; break;
case 'os_version': case 'os_version':
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>

View File

@@ -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,70 +31,74 @@ export default function OverviewTopEvents({
title: 'Top events', title: 'Top events',
btn: 'Your', btn: 'Your',
chart: { chart: {
limit: 10, report: {
projectId, limit: 10,
startDate, projectId,
endDate, startDate,
events: [ endDate,
{ events: [
segment: 'event', {
filters: [ segment: 'event',
...filters, filters: [
{ ...filters,
id: 'ex_session', {
name: 'name', id: 'ex_session',
operator: 'isNot', name: 'name',
value: ['session_start', 'session_end', 'screen_view'], operator: 'isNot',
}, value: ['session_start', 'session_end', 'screen_view'],
], },
id: 'A', ],
name: '*', id: 'A',
}, name: '*',
], },
breakdowns: [ ],
{ breakdowns: [
id: 'A', {
name: 'name', id: 'A',
}, name: 'name',
], },
chartType, ],
lineType: 'monotone', chartType,
interval: interval, lineType: 'monotone',
name: 'Your top events', interval: interval,
range: range, name: 'Your top events',
previous: previous, range: range,
metric: 'sum', previous: previous,
metric: 'sum',
},
}, },
}, },
all: { all: {
title: 'Top events', title: 'Top events',
btn: 'All', btn: 'All',
chart: { chart: {
limit: 10, report: {
projectId, limit: 10,
startDate, projectId,
endDate, startDate,
events: [ endDate,
{ events: [
segment: 'event', {
filters: [...filters], segment: 'event',
id: 'A', filters: [...filters],
name: '*', id: 'A',
}, name: '*',
], },
breakdowns: [ ],
{ breakdowns: [
id: 'A', {
name: 'name', id: 'A',
}, name: 'name',
], },
chartType, ],
lineType: 'monotone', chartType,
interval: interval, lineType: 'monotone',
name: 'All top events', interval: interval,
range: range, name: 'All top events',
previous: previous, range: range,
metric: 'sum', previous: previous,
metric: 'sum',
},
}, },
}, },
conversions: { conversions: {
@@ -102,39 +106,41 @@ export default function OverviewTopEvents({
btn: 'Conversions', btn: 'Conversions',
hide: conversions.length === 0, hide: conversions.length === 0,
chart: { chart: {
limit: 10, report: {
projectId, limit: 10,
startDate, projectId,
endDate, startDate,
events: [ endDate,
{ events: [
segment: 'event', {
filters: [ segment: 'event',
...filters, filters: [
{ ...filters,
id: 'conversion', {
name: 'name', id: 'conversion',
operator: 'is', name: 'name',
value: conversions, operator: 'is',
}, value: conversions,
], },
id: 'A', ],
name: '*', id: 'A',
}, name: '*',
], },
breakdowns: [ ],
{ breakdowns: [
id: 'A', {
name: 'name', id: 'A',
}, name: 'name',
], },
chartType, ],
lineType: 'monotone', chartType,
interval: interval, lineType: 'monotone',
name: 'Conversions', interval: interval,
range: range, name: 'Conversions',
previous: previous, range: range,
metric: 'sum', previous: previous,
metric: 'sum',
},
}, },
}, },
}); });
@@ -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>

View File

@@ -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,110 +30,122 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
title: 'Top countries', title: 'Top countries',
btn: 'Countries', btn: 'Countries',
chart: { chart: {
renderSerieName(name) { options: {
return getCountry(name[0]) || NOT_SET_VALUE; renderSerieName(name) {
return getCountry(name[0]) || NOT_SET_VALUE;
},
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'country',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top countries',
range: range,
previous: previous,
metric: 'sum',
}, },
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'country',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top countries',
range: range,
previous: previous,
metric: 'sum',
}, },
}, },
regions: { regions: {
title: 'Top regions', title: 'Top regions',
btn: 'Regions', btn: 'Regions',
chart: { chart: {
renderSerieName(name) { options: {
return name[1] || NOT_SET_VALUE; renderSerieName(name) {
return name[1] || NOT_SET_VALUE;
},
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'country',
},
{
id: 'B',
name: 'region',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top regions',
range: range,
previous: previous,
metric: 'sum',
}, },
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'country',
},
{
id: 'B',
name: 'region',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top regions',
range: range,
previous: previous,
metric: 'sum',
}, },
}, },
cities: { cities: {
title: 'Top cities', title: 'Top cities',
btn: 'Cities', btn: 'Cities',
chart: { chart: {
renderSerieName(name) { options: {
return name[1] || NOT_SET_VALUE; renderSerieName(name) {
return name[1] || NOT_SET_VALUE;
},
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'country',
},
{
id: 'B',
name: 'city',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top cities',
range: range,
previous: previous,
metric: 'sum',
}, },
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'country',
},
{
id: 'B',
name: 'city',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top cities',
range: range,
previous: previous,
metric: 'sum',
}, },
}, },
}); });
@@ -157,29 +168,34 @@ 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'); setFilter('country', item.names[0]);
setFilter('country', item.names[0]); break;
break; case 'regions':
case 'regions': setWidget('cities');
setWidget('cities'); setFilter('region', item.names[1]);
setFilter('region', item.names[1]); break;
break; case 'cities':
case 'cities': 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,

View File

@@ -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,108 +48,120 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
title: 'Top pages', title: 'Top pages',
btn: 'Top pages', btn: 'Top pages',
chart: { chart: {
renderSerieName, options: {
limit: 10, renderSerieName,
projectId, },
startDate, report: {
endDate, limit: 10,
events: [ projectId,
{ startDate,
segment: 'event', endDate,
filters, events: [
id: 'A', {
name: 'screen_view', segment: 'event',
}, filters,
], id: 'A',
breakdowns: [ name: 'screen_view',
{ },
id: 'A', ],
name: 'origin', breakdowns: [
}, {
{ id: 'A',
id: 'B', name: 'origin',
name: 'path', },
}, {
], id: 'B',
chartType, name: 'path',
lineType: 'monotone', },
interval, ],
name: 'Top pages', chartType,
range, lineType: 'monotone',
previous, interval,
metric: 'sum', name: 'Top pages',
range,
previous,
metric: 'sum',
},
}, },
}, },
entries: { entries: {
title: 'Entry Pages', title: 'Entry Pages',
btn: 'Entries', btn: 'Entries',
chart: { chart: {
renderSerieName, options: {
limit: 10, renderSerieName,
projectId, },
startDate, report: {
endDate, limit: 10,
events: [ projectId,
{ startDate,
segment: 'event', endDate,
filters, events: [
id: 'A', {
name: 'session_start', segment: 'event',
}, filters,
], id: 'A',
breakdowns: [ name: 'session_start',
{ },
id: 'A', ],
name: 'origin', breakdowns: [
}, {
{ id: 'A',
id: 'B', name: 'origin',
name: 'path', },
}, {
], id: 'B',
chartType, name: 'path',
lineType: 'monotone', },
interval, ],
name: 'Entry Pages', chartType,
range, lineType: 'monotone',
previous, interval,
metric: 'sum', name: 'Entry Pages',
range,
previous,
metric: 'sum',
},
}, },
}, },
exits: { exits: {
title: 'Exit Pages', title: 'Exit Pages',
btn: 'Exits', btn: 'Exits',
chart: { chart: {
renderSerieName, options: {
limit: 10, renderSerieName,
projectId, },
startDate, report: {
endDate, limit: 10,
events: [ projectId,
{ startDate,
segment: 'event', endDate,
filters, events: [
id: 'A', {
name: 'session_end', segment: 'event',
}, filters,
], id: 'A',
breakdowns: [ name: 'session_end',
{ },
id: 'A', ],
name: 'origin', breakdowns: [
}, {
{ id: 'A',
id: 'B', name: 'origin',
name: 'path', },
}, {
], id: 'B',
chartType, name: 'path',
lineType: 'monotone', },
interval, ],
name: 'Exit Pages', chartType,
range, lineType: 'monotone',
previous, interval,
metric: 'sum', name: 'Exit Pages',
range,
previous,
metric: 'sum',
},
}, },
}, },
bot: { bot: {
@@ -177,32 +193,37 @@ 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, onClick: () => {
onClick: () => { window.open(serie.names.join(''), '_blank');
window.open(serie.names.join(''), '_blank'); },
}, },
}, {
{ title: 'Set filter',
title: 'Set filter', icon: FilterIcon,
icon: FilterIcon, onClick: () => {
onClick: () => { 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

View File

@@ -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,248 +30,264 @@ export default function OverviewTopSources({
title: 'Top sources', title: 'Top sources',
btn: 'All', btn: 'All',
chart: { chart: {
limit: 10, report: {
projectId, limit: 10,
startDate, projectId,
endDate, startDate,
events: [ endDate,
{ events: [
segment: 'event', {
filters: filters, segment: 'event',
id: 'A', filters: filters,
name: isPageFilter ? 'screen_view' : 'session_start', id: 'A',
}, name: isPageFilter ? 'screen_view' : 'session_start',
], },
breakdowns: [ ],
{ breakdowns: [
id: 'A', {
name: 'referrer_name', id: 'A',
}, name: 'referrer_name',
], },
chartType, ],
lineType: 'monotone', chartType,
interval: interval, lineType: 'monotone',
name: 'Top sources', interval: interval,
range: range, name: 'Top sources',
previous: previous, range: range,
metric: 'sum', previous: previous,
metric: 'sum',
},
}, },
}, },
domain: { domain: {
title: 'Top urls', title: 'Top urls',
btn: 'URLs', btn: 'URLs',
chart: { chart: {
limit: 10, report: {
projectId, limit: 10,
startDate, projectId,
endDate, startDate,
events: [ endDate,
{ events: [
segment: 'event', {
filters: filters, segment: 'event',
id: 'A', filters: filters,
name: isPageFilter ? 'screen_view' : 'session_start', id: 'A',
}, name: isPageFilter ? 'screen_view' : 'session_start',
], },
breakdowns: [ ],
{ breakdowns: [
id: 'A', {
name: 'referrer', id: 'A',
}, name: 'referrer',
], },
chartType, ],
lineType: 'monotone', chartType,
interval: interval, lineType: 'monotone',
name: 'Top urls', interval: interval,
range: range, name: 'Top urls',
previous: previous, range: range,
metric: 'sum', previous: previous,
metric: 'sum',
},
}, },
}, },
type: { type: {
title: 'Top types', title: 'Top types',
btn: 'Types', btn: 'Types',
chart: { chart: {
limit: 10, report: {
projectId, limit: 10,
startDate, projectId,
endDate, startDate,
events: [ endDate,
{ events: [
segment: 'event', {
filters: filters, segment: 'event',
id: 'A', filters: filters,
name: isPageFilter ? 'screen_view' : 'session_start', id: 'A',
}, name: isPageFilter ? 'screen_view' : 'session_start',
], },
breakdowns: [ ],
{ breakdowns: [
id: 'A', {
name: 'referrer_type', id: 'A',
}, name: 'referrer_type',
], },
chartType, ],
lineType: 'monotone', chartType,
interval: interval, lineType: 'monotone',
name: 'Top types', interval: interval,
range: range, name: 'Top types',
previous: previous, range: range,
metric: 'sum', previous: previous,
metric: 'sum',
},
}, },
}, },
utm_source: { utm_source: {
title: 'UTM Source', title: 'UTM Source',
btn: 'Source', btn: 'Source',
chart: { chart: {
limit: 10, report: {
projectId, limit: 10,
startDate, projectId,
endDate, startDate,
events: [ endDate,
{ events: [
segment: 'event', {
filters, segment: 'event',
id: 'A', filters,
name: isPageFilter ? 'screen_view' : 'session_start', id: 'A',
}, name: isPageFilter ? 'screen_view' : 'session_start',
], },
breakdowns: [ ],
{ breakdowns: [
id: 'A', {
name: 'properties.__query.utm_source', id: 'A',
}, name: 'properties.__query.utm_source',
], },
chartType, ],
lineType: 'monotone', chartType,
interval: interval, lineType: 'monotone',
name: 'UTM Source', interval: interval,
range: range, name: 'UTM Source',
previous: previous, range: range,
metric: 'sum', previous: previous,
metric: 'sum',
},
}, },
}, },
utm_medium: { utm_medium: {
title: 'UTM Medium', title: 'UTM Medium',
btn: 'Medium', btn: 'Medium',
chart: { chart: {
limit: 10, report: {
projectId, limit: 10,
startDate, projectId,
endDate, startDate,
events: [ endDate,
{ events: [
segment: 'event', {
filters, segment: 'event',
id: 'A', filters,
name: isPageFilter ? 'screen_view' : 'session_start', id: 'A',
}, name: isPageFilter ? 'screen_view' : 'session_start',
], },
breakdowns: [ ],
{ breakdowns: [
id: 'A', {
name: 'properties.__query.utm_medium', id: 'A',
}, name: 'properties.__query.utm_medium',
], },
chartType, ],
lineType: 'monotone', chartType,
interval: interval, lineType: 'monotone',
name: 'UTM Medium', interval: interval,
range: range, name: 'UTM Medium',
previous: previous, range: range,
metric: 'sum', previous: previous,
metric: 'sum',
},
}, },
}, },
utm_campaign: { utm_campaign: {
title: 'UTM Campaign', title: 'UTM Campaign',
btn: 'Campaign', btn: 'Campaign',
chart: { chart: {
limit: 10, report: {
projectId, limit: 10,
startDate, projectId,
endDate, startDate,
events: [ endDate,
{ events: [
segment: 'event', {
filters, segment: 'event',
id: 'A', filters,
name: isPageFilter ? 'screen_view' : 'session_start', id: 'A',
}, name: isPageFilter ? 'screen_view' : 'session_start',
], },
breakdowns: [ ],
{ breakdowns: [
id: 'A', {
name: 'properties.__query.utm_campaign', id: 'A',
}, name: 'properties.__query.utm_campaign',
], },
chartType, ],
lineType: 'monotone', chartType,
interval: interval, lineType: 'monotone',
name: 'UTM Campaign', interval: interval,
range: range, name: 'UTM Campaign',
previous: previous, range: range,
metric: 'sum', previous: previous,
metric: 'sum',
},
}, },
}, },
utm_term: { utm_term: {
title: 'UTM Term', title: 'UTM Term',
btn: 'Term', btn: 'Term',
chart: { chart: {
limit: 10, report: {
projectId, limit: 10,
startDate, projectId,
endDate, startDate,
events: [ endDate,
{ events: [
segment: 'event', {
filters, segment: 'event',
id: 'A', filters,
name: isPageFilter ? 'screen_view' : 'session_start', id: 'A',
}, name: isPageFilter ? 'screen_view' : 'session_start',
], },
breakdowns: [ ],
{ breakdowns: [
id: 'A', {
name: 'properties.__query.utm_term', id: 'A',
}, name: 'properties.__query.utm_term',
], },
chartType, ],
lineType: 'monotone', chartType,
interval: interval, lineType: 'monotone',
name: 'UTM Term', interval: interval,
range: range, name: 'UTM Term',
previous: previous, range: range,
metric: 'sum', previous: previous,
metric: 'sum',
},
}, },
}, },
utm_content: { utm_content: {
title: 'UTM Content', title: 'UTM Content',
btn: 'Content', btn: 'Content',
chart: { chart: {
limit: 10, report: {
projectId, limit: 10,
startDate, projectId,
endDate, startDate,
events: [ endDate,
{ events: [
segment: 'event', {
filters, segment: 'event',
id: 'A', filters,
name: isPageFilter ? 'screen_view' : 'session_start', id: 'A',
}, name: isPageFilter ? 'screen_view' : 'session_start',
], },
breakdowns: [ ],
{ breakdowns: [
id: 'A', {
name: 'properties.__query.utm_content', id: 'A',
}, name: 'properties.__query.utm_content',
], },
chartType, ],
lineType: 'monotone', chartType,
interval: interval, lineType: 'monotone',
name: 'UTM Content', interval: interval,
range: range, name: 'UTM Content',
previous: previous, range: range,
metric: 'sum', previous: previous,
metric: 'sum',
},
}, },
}, },
}); });
@@ -295,44 +311,47 @@ 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) => { }}
switch (widget.key) { options={{
case 'all': onClick: (item) => {
setFilter('referrer_name', item.names[0]); switch (widget.key) {
setWidget('domain'); case 'all':
break; setFilter('referrer_name', item.names[0]);
case 'domain': setWidget('domain');
setFilter('referrer', item.names[0]); break;
break; case 'domain':
case 'type': setFilter('referrer', item.names[0]);
setFilter('referrer_type', item.names[0]); break;
setWidget('domain'); case 'type':
break; setFilter('referrer_type', item.names[0]);
case 'utm_source': setWidget('domain');
setFilter('properties.__query.utm_source', item.names[0]); break;
break; case 'utm_source':
case 'utm_medium': setFilter('properties.__query.utm_source', item.names[0]);
setFilter('properties.__query.utm_medium', item.names[0]); break;
break; case 'utm_medium':
case 'utm_campaign': setFilter('properties.__query.utm_medium', item.names[0]);
setFilter('properties.__query.utm_campaign', item.names[0]); break;
break; case 'utm_campaign':
case 'utm_term': setFilter('properties.__query.utm_campaign', item.names[0]);
setFilter('properties.__query.utm_term', item.names[0]); break;
break; case 'utm_term':
case 'utm_content': setFilter('properties.__query.utm_term', item.names[0]);
setFilter('properties.__query.utm_content', item.names[0]); break;
break; case 'utm_content':
} setFilter('properties.__query.utm_content', item.names[0]);
break;
}
},
}} }}
/> />
</WidgetBody> </WidgetBody>
<WidgetFooter> <WidgetFooter>
<OverviewDetailsButton chart={widget.chart} /> <OverviewDetailsButton chart={widget.chart.report} />
<OverviewChartToggle {...{ chartType, setChartType }} /> <OverviewChartToggle {...{ chartType, setChartType }} />
</WidgetFooter> </WidgetFooter>
</Widget> </Widget>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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({
current: { steps, totalSessions }, data: {
previous, current: { steps, totalSessions },
input, previous,
},
}: 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

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

View File

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

View File

@@ -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,38 +31,35 @@ 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={'50%'}
innerRadius={height / 4} outerRadius={'80%'}
outerRadius={height / 2.5} isAnimationActive={false}
isAnimationActive={false} label={renderLabel}
label={renderLabel} >
> {pieData.map((item) => {
{pieData.map((item) => { return (
return ( <Cell
<Cell key={item.id}
key={item.id} strokeWidth={2}
strokeWidth={2} stroke={item.color}
stroke={item.color} fill={item.color}
fill={item.color} />
/> );
); })}
})} </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>

View File

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

View File

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

View File

@@ -1,101 +0,0 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { api } from '@/trpc/client';
import debounce from 'lodash.debounce';
import isEqual from 'lodash.isequal';
import type { IChartProps } from '@openpanel/validation';
import { ChartEmpty } from './ChartEmpty';
import { useChartContext } from './ChartProvider';
import { ReportAreaChart } from './ReportAreaChart';
import { ReportBarChart } from './ReportBarChart';
import { ReportHistogramChart } from './ReportHistogramChart';
import { ReportLineChart } from './ReportLineChart';
import { ReportMapChart } from './ReportMapChart';
import { ReportMetricChart } from './ReportMetricChart';
import { ReportPieChart } from './ReportPieChart';
export type ReportChartProps = IChartProps;
const pluckChartContext = (context: IChartProps) => ({
chartType: context.chartType,
interval: context.interval,
breakdowns: context.breakdowns,
range: context.range,
previous: context.previous,
formula: context.formula,
metric: context.metric,
projectId: context.projectId,
startDate: context.startDate,
endDate: context.endDate,
limit: context.limit,
offset: context.offset,
events: context.events.map((event) => ({
...event,
filters: event.filters?.filter((filter) => filter.value.length > 0),
})),
});
// TODO: Quick hack to avoid re-fetching
// Will refactor the entire chart component soon anyway...
function useChartData() {
const context = useChartContext();
const [params, setParams] = useState(() => pluckChartContext(context));
const debouncedSetParams = useMemo(() => debounce(setParams, 500), []);
useEffect(() => {
const newParams = pluckChartContext(context);
if (!isEqual(newParams, params)) {
debouncedSetParams(newParams);
}
return () => {
debouncedSetParams.cancel();
};
}, [context, params, debouncedSetParams]);
return api.chart.chart.useSuspenseQuery(params, {
keepPreviousData: true,
staleTime: 1000 * 60 * 1,
});
}
export function Chart() {
const { chartType } = useChartContext();
const [data] = useChartData();
if (data.series.length === 0) {
return <ChartEmpty />;
}
if (chartType === 'map') {
return <ReportMapChart data={data} />;
}
if (chartType === 'histogram') {
return <ReportHistogramChart data={data} />;
}
if (chartType === 'bar') {
return <ReportBarChart data={data} />;
}
if (chartType === 'metric') {
return <ReportMetricChart data={data} />;
}
if (chartType === 'pie') {
return <ReportPieChart data={data} />;
}
if (chartType === 'linear') {
return <ReportLineChart data={data} />;
}
if (chartType === 'area') {
return <ReportAreaChart data={data} />;
}
return <p>Unknown chart type</p>;
}

View File

@@ -1,29 +0,0 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { useChartContext } from './ChartProvider';
import { MetricCardEmpty } from './MetricCard';
import { ResponsiveContainer } from './ResponsiveContainer';
export function ChartEmpty() {
const { editMode, chartType } = useChartContext();
if (editMode) {
return (
<FullPageEmptyState title="No data">
We could not find any data for selected events and filter.
</FullPageEmptyState>
);
}
if (chartType === 'metric') {
return <MetricCardEmpty />;
}
return (
<ResponsiveContainer>
<div className={'flex h-full w-full items-center justify-center'}>
No data
</div>
</ResponsiveContainer>
);
}

View File

@@ -1,20 +0,0 @@
import { cn } from '@/utils/cn';
import { ResponsiveContainer } from './ResponsiveContainer';
interface ChartLoadingProps {
className?: string;
aspectRatio?: number;
}
export function ChartLoading({ className, aspectRatio }: ChartLoadingProps) {
return (
<ResponsiveContainer aspectRatio={aspectRatio}>
<div
className={cn(
'h-full w-full animate-pulse rounded bg-def-200',
className
)}
/>
</ResponsiveContainer>
);
}

View File

@@ -1,44 +0,0 @@
'use client';
import { createContext, useContext } from 'react';
import type { LucideIcon } from 'lucide-react';
import type { IChartProps, IChartSerie } from '@openpanel/validation';
export interface IChartContextType extends IChartProps {
hideXAxis?: boolean;
hideYAxis?: boolean;
aspectRatio?: number;
editMode?: boolean;
hideID?: boolean;
onClick?: (item: IChartSerie) => void;
renderSerieName?: (names: string[]) => React.ReactNode;
renderSerieIcon?: (serie: IChartSerie) => React.ReactNode;
dropdownMenuContent?: (serie: IChartSerie) => {
icon: LucideIcon;
title: string;
onClick: () => void;
}[];
}
type IChartProviderProps = {
children: React.ReactNode;
} & IChartContextType;
const ChartContext = createContext<IChartContextType | null>(null);
export function ChartProvider({ children, ...props }: IChartProviderProps) {
return (
<ChartContext.Provider
value={
props.chartType === 'funnel' ? { ...props, previous: true } : props
}
>
{children}
</ChartContext.Provider>
);
}
export function useChartContext() {
return useContext(ChartContext)!;
}

View File

@@ -1,36 +0,0 @@
'use client';
import React, { useEffect, useRef } from 'react';
import { cn } from '@/utils/cn';
import { useInViewport } from 'react-in-viewport';
import type { IChartRoot } from '.';
import { ChartRoot } from '.';
import { ChartLoading } from './ChartLoading';
export function LazyChart({
className,
...props
}: IChartRoot & { className?: string }) {
const ref = useRef<HTMLDivElement>(null);
const once = useRef(false);
const { inViewport } = useInViewport(ref, undefined, {
disconnectOnLeave: true,
});
useEffect(() => {
if (inViewport) {
once.current = true;
}
}, [inViewport]);
return (
<div ref={ref} className={cn('w-full', className)}>
{once.current || inViewport ? (
<ChartRoot {...props} editMode={false} />
) : (
<ChartLoading aspectRatio={props.aspectRatio} />
)}
</div>
);
}

View File

@@ -1,116 +0,0 @@
import React from 'react';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useNumber } from '@/hooks/useNumerFormatter';
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import {
Area,
AreaChart,
CartesianGrid,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { getYAxisWidth } from './chart-utils';
import { useChartContext } from './ChartProvider';
import { ReportChartTooltip } from './ReportChartTooltip';
import { ReportTable } from './ReportTable';
import { ResponsiveContainer } from './ResponsiveContainer';
interface ReportAreaChartProps {
data: IChartData;
}
export function ReportAreaChart({ data }: ReportAreaChartProps) {
const { editMode, lineType, interval } = useChartContext();
const { series, setVisibleSeries } = useVisibleSeries(data);
const formatDate = useFormatDateInterval(interval);
const rechartData = useRechartDataModel(series);
const number = useNumber();
return (
<>
<div className={cn(editMode && 'card p-4')}>
<ResponsiveContainer>
{({ width, height }) => (
<AreaChart width={width} height={height} data={rechartData}>
<Tooltip content={<ReportChartTooltip />} />
<XAxis
axisLine={false}
fontSize={12}
dataKey="date"
tickFormatter={(m: string) => formatDate(m)}
tickLine={false}
allowDuplicatedCategory={false}
/>
<YAxis
width={getYAxisWidth(data.metrics.max)}
fontSize={12}
axisLine={false}
tickLine={false}
allowDecimals={false}
tickFormatter={number.short}
/>
{series.map((serie) => {
const color = getChartColor(serie.index);
return (
<React.Fragment key={serie.id}>
<defs>
<linearGradient
id={`color${color}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={color}
stopOpacity={0.8}
></stop>
<stop
offset="100%"
stopColor={color}
stopOpacity={0.1}
></stop>
</linearGradient>
</defs>
<Area
key={serie.id}
type={lineType}
isAnimationActive={false}
strokeWidth={2}
dataKey={`${serie.id}:count`}
stroke={color}
fill={`url(#color${color})`}
stackId={'1'}
fillOpacity={1}
/>
</React.Fragment>
);
})}
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}
vertical={false}
className="stroke-def-200"
/>
</AreaChart>
)}
</ResponsiveContainer>
</div>
{editMode && (
<ReportTable
data={data}
visibleSeries={series}
setVisibleSeries={setVisibleSeries}
/>
)}
</>
);
}

View File

@@ -1,137 +0,0 @@
import React from 'react';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useNumber } from '@/hooks/useNumerFormatter';
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { getChartColor, theme } from '@/utils/theme';
import { useTheme } from 'next-themes';
import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from 'recharts';
import { getYAxisWidth } from './chart-utils';
import { useChartContext } from './ChartProvider';
import { ReportChartTooltip } from './ReportChartTooltip';
import { ReportTable } from './ReportTable';
import { ResponsiveContainer } from './ResponsiveContainer';
interface ReportHistogramChartProps {
data: IChartData;
}
function BarHover({ x, y, width, height, top, left, right, bottom }: any) {
const themeMode = useTheme();
const bg =
themeMode?.theme === 'dark'
? theme.colors['def-100']
: theme.colors['def-300'];
return (
<rect
{...{ x, y, width, height, top, left, right, bottom }}
rx="3"
fill={bg}
fillOpacity={0.5}
/>
);
}
export function ReportHistogramChart({ data }: ReportHistogramChartProps) {
const { editMode, previous, interval, aspectRatio } = useChartContext();
const formatDate = useFormatDateInterval(interval);
const { series, setVisibleSeries } = useVisibleSeries(data);
const rechartData = useRechartDataModel(series);
const number = useNumber();
return (
<>
<div className={cn('w-full', editMode && 'card p-4')}>
<ResponsiveContainer aspectRatio={aspectRatio}>
{({ width, height }) => (
<BarChart
width={width}
height={height}
data={rechartData}
barCategoryGap={10}
>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
className="stroke-def-200"
/>
<Tooltip content={<ReportChartTooltip />} cursor={<BarHover />} />
<XAxis
fontSize={12}
dataKey="date"
tickFormatter={formatDate}
tickLine={false}
axisLine={false}
/>
<YAxis
fontSize={12}
axisLine={false}
tickLine={false}
width={getYAxisWidth(data.metrics.max)}
allowDecimals={false}
domain={[0, data.metrics.max]}
tickFormatter={number.short}
/>
{series.map((serie) => {
return (
<React.Fragment key={serie.id}>
<defs>
<linearGradient
id="colorGradient"
x1="0"
y1="1"
x2="0"
y2="0"
>
<stop
offset="0%"
stopColor={getChartColor(serie.index)}
stopOpacity={0.7}
/>
<stop
offset="100%"
stopColor={getChartColor(serie.index)}
stopOpacity={1}
/>
</linearGradient>
</defs>
{previous && (
<Bar
key={`${serie.id}:prev`}
name={`${serie.id}:prev`}
dataKey={`${serie.id}:prev:count`}
fill={getChartColor(serie.index)}
fillOpacity={0.1}
radius={3}
barSize={20} // Adjust the bar width here
/>
)}
<Bar
key={serie.id}
name={serie.id}
dataKey={`${serie.id}:count`}
fill="url(#colorGradient)"
radius={3}
fillOpacity={1}
barSize={20} // Adjust the bar width here
/>
</React.Fragment>
);
})}
</BarChart>
)}
</ResponsiveContainer>
</div>
{editMode && (
<ReportTable
data={data}
visibleSeries={series}
setVisibleSeries={setVisibleSeries}
/>
)}
</>
);
}

View File

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

View File

@@ -1,45 +0,0 @@
'use client';
import AutoSizer from 'react-virtualized-auto-sizer';
import { DEFAULT_ASPECT_RATIO } from '@openpanel/constants';
interface ResponsiveContainerProps {
aspectRatio?: number;
children:
| ((props: { width: number; height: number }) => React.ReactNode)
| React.ReactNode;
}
export function ResponsiveContainer({
children,
aspectRatio = 0.5625,
}: ResponsiveContainerProps) {
const maxHeight = 300;
return (
<div
className="w-full"
style={{
aspectRatio: 1 / (aspectRatio || DEFAULT_ASPECT_RATIO),
maxHeight,
}}
>
{typeof children === 'function' ? (
<AutoSizer disableHeight>
{({ width }) =>
children({
width,
height: Math.min(
maxHeight,
width * aspectRatio || DEFAULT_ASPECT_RATIO
),
})
}
</AutoSizer>
) : (
children
)}
</div>
);
}

View File

@@ -1,11 +0,0 @@
const formatter = new Intl.NumberFormat('en', {
notation: 'compact',
});
export function getYAxisWidth(value: number) {
if (!isFinite(value)) {
return 7.8 + 7.8;
}
return formatter.format(value).toString().length * 7.8 + 7.8;
}

View File

@@ -1,90 +0,0 @@
'use client';
import { Suspense, useEffect, useState } from 'react';
import * as Portal from '@radix-ui/react-portal';
import type { IChartProps } from '@openpanel/validation';
import { Funnel } from '../funnel';
import { Chart } from './Chart';
import { ChartLoading } from './ChartLoading';
import type { IChartContextType } from './ChartProvider';
import { ChartProvider } from './ChartProvider';
import { MetricCardLoading } from './MetricCard';
export type IChartRoot = IChartContextType;
export function ChartRoot(props: IChartContextType) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return props.chartType === 'metric' ? (
<MetricCardLoading />
) : (
<ChartLoading aspectRatio={props.aspectRatio} />
);
}
return (
<Suspense
fallback={
props.chartType === 'metric' ? (
<MetricCardLoading />
) : (
<ChartLoading aspectRatio={props.aspectRatio} />
)
}
>
<ChartProvider {...props}>
{props.chartType === 'funnel' ? <Funnel /> : <Chart />}
</ChartProvider>
</Suspense>
);
}
interface ChartRootShortcutProps {
projectId: IChartProps['projectId'];
range?: IChartProps['range'];
previous?: IChartProps['previous'];
chartType?: IChartProps['chartType'];
interval?: IChartProps['interval'];
events: IChartProps['events'];
breakdowns?: IChartProps['breakdowns'];
lineType?: IChartProps['lineType'];
hideXAxis?: boolean;
aspectRatio?: number;
}
export const ChartRootShortcut = ({
hideXAxis,
projectId,
range = '7d',
previous = false,
chartType = 'linear',
interval = 'day',
events,
breakdowns,
aspectRatio,
lineType = 'monotone',
}: ChartRootShortcutProps) => {
return (
<ChartRoot
projectId={projectId}
range={range}
breakdowns={breakdowns ?? []}
previous={previous}
chartType={chartType}
interval={interval}
name="Random"
lineType={lineType}
metric="sum"
events={events}
aspectRatio={aspectRatio}
hideXAxis={hideXAxis}
/>
);
};

View File

@@ -1,176 +0,0 @@
// @ts-nocheck
'use client';
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from '@/components/ui/carousel';
import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { round } from '@/utils/math';
import { ArrowRightIcon } from 'lucide-react';
import { useChartContext } from '../chart/ChartProvider';
function FunnelChart({ from, to }: { from: number; to: number }) {
const fromY = 100 - from;
const toY = 100 - to;
const steps = [
`M0,${fromY}`,
'L0,100',
'L100,100',
`L100,${toY}`,
`L0,${fromY}`,
];
return (
<svg viewBox="0 0 100 100">
<defs>
<linearGradient
id="blue"
x1="50"
y1="100"
x2="50"
y2="0"
gradientUnits="userSpaceOnUse"
>
{/* bottom */}
<stop offset="0%" stop-color="#2564eb" />
{/* top */}
<stop offset="100%" stop-color="#2564eb" />
</linearGradient>
<linearGradient
id="red"
x1="50"
y1="100"
x2="50"
y2="0"
gradientUnits="userSpaceOnUse"
>
{/* bottom */}
<stop offset="0%" stop-color="#f87171" />
{/* top */}
<stop offset="100%" stop-color="#fca5a5" />
</linearGradient>
</defs>
<rect
x="0"
y={fromY}
width="100"
height="100"
fill="url(#red)"
fillOpacity={0.2}
/>
<path d={steps.join(' ')} fill="url(#blue)" />
</svg>
);
}
function getDropoffColor(value: number) {
if (value > 80) {
return 'text-red-600';
}
if (value > 50) {
return 'text-orange-600';
}
if (value > 30) {
return 'text-yellow-600';
}
return 'text-green-600';
}
export function FunnelSteps({
current: { steps, totalSessions },
}: RouterOutputs['chart']['funnel']) {
const { editMode } = useChartContext();
return (
<Carousel className="w-full" opts={{ loop: false, dragFree: true }}>
<CarouselContent>
<CarouselItem className={'flex-[0_0_0] pl-3'} />
{steps.map((step, index, list) => {
const finalStep = index === list.length - 1;
return (
<CarouselItem
className={cn(
'max-w-full flex-[0_0_250px] p-0 px-1',
editMode && 'flex-[0_0_320px]'
)}
key={step.event.id}
>
<div className="card divide-y divide-border bg-card">
<div className="p-4">
<p className="text-muted-foreground">Step {index + 1}</p>
<h3 className="font-bold">
{step.event.displayName || step.event.name}
</h3>
</div>
<div className="relative aspect-square">
<FunnelChart from={step.prevPercent} to={step.percent} />
<div className="absolute left-0 right-0 top-0 flex flex-col bg-card/40 p-4">
<div className="font-medium uppercase text-muted-foreground">
Sessions
</div>
<div className="flex items-center text-3xl font-bold uppercase">
<span className="text-muted-foreground">
{step.before}
</span>
<ArrowRightIcon size={16} className="mx-2" />
<span>{step.current}</span>
</div>
{index !== 0 && (
<>
<div className="text-muted-foreground">
{step.current} of {totalSessions} (
{round(step.percent, 1)}%)
</div>
</>
)}
</div>
</div>
{finalStep ? (
<div className={cn('flex flex-col items-center p-4')}>
<div className="text-sm font-medium uppercase">
Conversion
</div>
<div
className={cn(
'text-3xl font-bold uppercase',
getDropoffColor(step.dropoff.percent)
)}
>
{round(step.percent, 1)}%
</div>
<div className="mt-0 font-medium uppercase text-muted-foreground">
Converted {step.current} of {totalSessions} sessions
</div>
</div>
) : (
<div className={cn('flex flex-col items-center p-4')}>
<div className="text-sm font-medium uppercase">Dropoff</div>
<div
className={cn(
'text-3xl font-bold uppercase',
getDropoffColor(step.dropoff.percent)
)}
>
{round(step.dropoff.percent, 1)}%
</div>
<div className="mt-0 font-medium uppercase text-muted-foreground">
Lost {step.dropoff.count} sessions
</div>
</div>
)}
</div>
</CarouselItem>
);
})}
<CarouselItem className={'flex-[0_0_0px] pl-3'} />
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
);
}

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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={[

View File

@@ -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 */