multiple breakpoints

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-06-20 23:25:18 +02:00
parent c07f0d302c
commit cf8617e809
48 changed files with 908 additions and 432 deletions

View File

@@ -5,6 +5,7 @@ import { api } from '@/trpc/client';
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';
@@ -15,34 +16,22 @@ import { ReportPieChart } from './ReportPieChart';
export type ReportChartProps = IChartProps;
export function Chart({
interval,
events,
breakdowns,
chartType,
range,
lineType,
previous,
formula,
metric,
projectId,
startDate,
endDate,
limit,
offset,
}: ReportChartProps) {
const [references] = api.reference.getChartReferences.useSuspenseQuery(
{
projectId,
startDate,
endDate,
range,
},
{
staleTime: 1000 * 60 * 5,
}
);
export function Chart() {
const {
interval,
events,
breakdowns,
chartType,
range,
previous,
formula,
metric,
projectId,
startDate,
endDate,
limit,
offset,
} = useChartContext();
const [data] = api.chart.chart.useSuspenseQuery(
{
interval,
@@ -73,7 +62,7 @@ export function Chart({
}
if (chartType === 'histogram') {
return <ReportHistogramChart interval={interval} data={data} />;
return <ReportHistogramChart data={data} />;
}
if (chartType === 'bar') {
@@ -89,20 +78,11 @@ export function Chart({
}
if (chartType === 'linear') {
return (
<ReportLineChart
lineType={lineType}
interval={interval}
data={data}
references={references}
/>
);
return <ReportLineChart data={data} />;
}
if (chartType === 'area') {
return (
<ReportAreaChart lineType={lineType} interval={interval} data={data} />
);
return <ReportAreaChart data={data} />;
}
return <p>Unknown chart type</p>;

View File

@@ -1,113 +1,35 @@
'use client';
import {
createContext,
memo,
Suspense,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { createContext, useContext } from 'react';
import type { LucideIcon } from 'lucide-react';
import type { IChartProps, IChartSerie } from '@openpanel/validation';
import { ChartLoading } from './ChartLoading';
import { MetricCardLoading } from './MetricCard';
export interface ChartContextType extends IChartProps {
export interface IChartContextType extends IChartProps {
editMode?: boolean;
hideID?: boolean;
onClick?: (item: IChartSerie) => void;
limit?: number;
renderSerieName?: (names: string[]) => React.ReactNode;
renderSerieIcon?: (serie: IChartSerie) => React.ReactNode;
dropdownMenuContent?: (serie: IChartSerie) => {
icon: LucideIcon;
title: string;
onClick: () => void;
}[];
}
type ChartProviderProps = {
type IChartProviderProps = {
children: React.ReactNode;
} & ChartContextType;
} & IChartContextType;
const ChartContext = createContext<ChartContextType | null>({
events: [],
breakdowns: [],
chartType: 'linear',
lineType: 'monotone',
interval: 'day',
name: '',
range: '7d',
metric: 'sum',
previous: false,
projectId: '',
limit: undefined,
});
const ChartContext = createContext<IChartContextType | null>(null);
export function ChartProvider({
children,
editMode,
previous,
hideID,
limit,
...props
}: ChartProviderProps) {
export function ChartProvider({ children, ...props }: IChartProviderProps) {
return (
<ChartContext.Provider
value={useMemo(
() => ({
...props,
editMode: editMode ?? false,
previous: previous ?? false,
hideID: hideID ?? false,
limit,
}),
[editMode, previous, hideID, limit, props]
)}
>
{children}
</ChartContext.Provider>
<ChartContext.Provider value={props}>{children}</ChartContext.Provider>
);
}
export function withChartProivder<ComponentProps>(
WrappedComponent: React.FC<ComponentProps>
) {
const WithChartProvider = (props: ComponentProps & ChartContextType) => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return props.chartType === 'metric' ? (
<MetricCardLoading />
) : (
<ChartLoading />
);
}
return (
<Suspense
fallback={
props.chartType === 'metric' ? (
<MetricCardLoading />
) : (
<ChartLoading />
)
}
>
<ChartProvider {...props}>
<WrappedComponent {...props} />
</ChartProvider>
</Suspense>
);
};
WithChartProvider.displayName = `WithChartProvider(${
WrappedComponent.displayName ?? WrappedComponent.name ?? 'Component'
})`;
return memo(WithChartProvider);
}
export function useChartContext() {
return useContext(ChartContext)!;
}

View File

@@ -3,11 +3,11 @@
import React, { useEffect, useRef } from 'react';
import { useInViewport } from 'react-in-viewport';
import { ChartSwitch } from '.';
import type { IChartRoot } from '.';
import { ChartRoot } from '.';
import { ChartLoading } from './ChartLoading';
import type { ChartContextType } from './ChartProvider';
export function LazyChart(props: ChartContextType) {
export function LazyChart(props: IChartRoot) {
const ref = useRef<HTMLDivElement>(null);
const once = useRef(false);
const { inViewport } = useInViewport(ref, undefined, {
@@ -23,7 +23,7 @@ export function LazyChart(props: ChartContextType) {
return (
<div ref={ref}>
{once.current || inViewport ? (
<ChartSwitch {...props} editMode={false} />
<ChartRoot {...props} editMode={false} />
) : (
<ChartLoading />
)}

View File

@@ -1,6 +1,5 @@
'use client';
import { ColorSquare } from '@/components/color-square';
import { fancyMinutes, useNumber } from '@/hooks/useNumerFormatter';
import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn';
@@ -14,6 +13,7 @@ import {
PreviousDiffIndicatorText,
} from '../PreviousDiffIndicator';
import { useChartContext } from './ChartProvider';
import { SerieName } from './SerieName';
interface MetricCardProps {
serie: IChartData['series'][number];
@@ -28,7 +28,7 @@ export function MetricCard({
metric,
unit,
}: MetricCardProps) {
const { previousIndicatorInverted } = useChartContext();
const { previousIndicatorInverted, editMode } = useChartContext();
const number = useNumber();
const renderValue = (value: number, unitClassName?: string) => {
@@ -57,14 +57,15 @@ export function MetricCard({
return (
<div
className={cn(
'group relative h-[70px] overflow-hidden'
// '[#report-editor_&&]:card [#report-editor_&&]:px-4 [#report-editor_&&]:py-2'
'group relative h-[70px] overflow-hidden',
editMode && 'card h-[100px] px-4 py-2'
)}
key={serie.name}
key={serie.id}
>
<div
className={cn(
'pointer-events-none absolute -bottom-1 -left-1 -right-1 top-0 z-0 opacity-20 transition-opacity duration-300 group-hover:opacity-50'
'pointer-events-none absolute -bottom-1 -left-1 -right-1 top-0 z-0 opacity-20 transition-opacity duration-300 group-hover:opacity-50',
editMode && 'bottom-1'
)}
>
<AutoSizer>
@@ -91,9 +92,8 @@ export function MetricCard({
<div className="relative">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2 text-left font-semibold">
<ColorSquare>{serie.event.id}</ColorSquare>
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-muted-foreground">
{serie.name}
<SerieName name={serie.names} />
</span>
</div>
{/* <PreviousDiffIndicator {...serie.metrics.previous[metric]} /> */}

View File

@@ -14,8 +14,6 @@ import {
YAxis,
} from 'recharts';
import type { IChartLineType, IInterval } from '@openpanel/validation';
import { getYAxisWidth } from './chart-utils';
import { useChartContext } from './ChartProvider';
import { ReportChartTooltip } from './ReportChartTooltip';
@@ -24,16 +22,10 @@ import { ResponsiveContainer } from './ResponsiveContainer';
interface ReportAreaChartProps {
data: IChartData;
interval: IInterval;
lineType: IChartLineType;
}
export function ReportAreaChart({
lineType,
interval,
data,
}: ReportAreaChartProps) {
const { editMode } = useChartContext();
export function ReportAreaChart({ data }: ReportAreaChartProps) {
const { editMode, lineType, interval } = useChartContext();
const { series, setVisibleSeries } = useVisibleSeries(data);
const formatDate = useFormatDateInterval(interval);
const rechartData = useRechartDataModel(series);
@@ -65,7 +57,7 @@ export function ReportAreaChart({
{series.map((serie) => {
const color = getChartColor(serie.index);
return (
<React.Fragment key={serie.name}>
<React.Fragment key={serie.id}>
<defs>
<linearGradient
id={`color${color}`}
@@ -87,7 +79,7 @@ export function ReportAreaChart({
</linearGradient>
</defs>
<Area
key={serie.name}
key={serie.id}
type={lineType}
isAnimationActive={false}
strokeWidth={2}

View File

@@ -1,9 +1,18 @@
'use client';
import { useMemo } from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useNumber } from '@/hooks/useNumerFormatter';
import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { DropdownMenuPortal } from '@radix-ui/react-dropdown-menu';
import { ExternalLinkIcon, FilterIcon } from 'lucide-react';
import { round } from '@openpanel/common';
import { NOT_SET_VALUE } from '@openpanel/constants';
@@ -11,13 +20,15 @@ import { NOT_SET_VALUE } from '@openpanel/constants';
import { PreviousDiffIndicatorText } from '../PreviousDiffIndicator';
import { useChartContext } from './ChartProvider';
import { SerieIcon } from './SerieIcon';
import { SerieName } from './SerieName';
interface ReportBarChartProps {
data: IChartData;
}
export function ReportBarChart({ data }: ReportBarChartProps) {
const { editMode, metric, onClick, limit } = useChartContext();
const { editMode, metric, onClick, limit, dropdownMenuContent } =
useChartContext();
const number = useNumber();
const series = useMemo(
() => (editMode ? data.series : data.series.slice(0, limit || 10)),
@@ -33,42 +44,64 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
)}
>
{series.map((serie) => {
const isClickable = serie.name !== NOT_SET_VALUE && onClick;
const isClickable = !serie.names.includes(NOT_SET_VALUE) && onClick;
const isDropDownEnabled =
!serie.names.includes(NOT_SET_VALUE) &&
(dropdownMenuContent?.(serie) || []).length > 0;
return (
<div
key={serie.name}
className={cn('relative', isClickable && 'cursor-pointer')}
{...(isClickable ? { onClick: () => onClick(serie) } : {})}
>
<div
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
style={{
width: `${(serie.metrics.sum / maxCount) * 100}%`,
}}
/>
<div className="relative z-10 flex w-full flex-1 items-center gap-4 overflow-hidden px-3 py-2">
<div className="flex flex-1 items-center gap-2 break-all font-medium">
<SerieIcon name={serie.name} />
{serie.name}
</div>
<div className="flex flex-shrink-0 items-center justify-end gap-4">
<PreviousDiffIndicatorText
{...serie.metrics.previous?.[metric]}
className="text-xs font-medium"
<DropdownMenu key={serie.id}>
<DropdownMenuTrigger asChild disabled={!isDropDownEnabled}>
<div
className={cn(
'relative',
(isClickable || isDropDownEnabled) && 'cursor-pointer'
)}
{...(isClickable && !isDropDownEnabled
? { onClick: () => onClick?.(serie) }
: {})}
>
<div
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
style={{
width: `${(serie.metrics.sum / maxCount) * 100}%`,
}}
/>
{serie.metrics.previous?.[metric]?.value}
<div className="text-muted-foreground">
{number.format(
round((serie.metrics.sum / data.metrics.sum) * 100, 2)
)}
%
</div>
<div className="font-bold">
{number.format(serie.metrics.sum)}
<div className="relative z-10 flex w-full flex-1 items-center gap-4 overflow-hidden px-3 py-2">
<div className="flex flex-1 items-center gap-2 break-all font-medium">
<SerieIcon name={serie.names[0]} />
<SerieName name={serie.names} />
</div>
<div className="flex flex-shrink-0 items-center justify-end gap-4">
<PreviousDiffIndicatorText
{...serie.metrics.previous?.[metric]}
className="text-xs font-medium"
/>
{serie.metrics.previous?.[metric]?.value}
<div className="text-muted-foreground">
{number.format(
round((serie.metrics.sum / data.metrics.sum) * 100, 2)
)}
%
</div>
<div className="font-bold">
{number.format(serie.metrics.sum)}
</div>
</div>
</div>
</div>
</div>
</div>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
{dropdownMenuContent?.(serie).map((item) => (
<DropdownMenuItem key={item.title} onClick={item.onClick}>
{item.icon && <item.icon size={16} className="mr-2" />}
{item.title}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu>
);
})}
</div>

View File

@@ -7,6 +7,8 @@ import type { IToolTipProps } from '@/types';
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
import { useChartContext } from './ChartProvider';
import { SerieIcon } from './SerieIcon';
import { SerieName } from './SerieName';
type ReportLineChartTooltipProps = IToolTipProps<{
value: number;
@@ -53,7 +55,7 @@ export function ReportChartTooltip({
) as IRechartPayloadItem;
return (
<React.Fragment key={data.name}>
<React.Fragment key={data.id}>
{index === 0 && data.date && (
<div className="flex justify-between gap-8">
<div>{formatDate(new Date(data.date))}</div>
@@ -65,8 +67,9 @@ export function ReportChartTooltip({
style={{ background: data.color }}
/>
<div className="flex flex-1 flex-col">
<div className="min-w-0 max-w-[200px] overflow-hidden text-ellipsis whitespace-nowrap font-medium">
{getLabel(data.name)}
<div className="flex items-center gap-1">
<SerieIcon name={data.names} />
<SerieName name={data.names} />
</div>
<div className="flex justify-between gap-8">
<div>{number.formatWithUnit(data.count, unit)}</div>

View File

@@ -17,7 +17,6 @@ import { ResponsiveContainer } from './ResponsiveContainer';
interface ReportHistogramChartProps {
data: IChartData;
interval: IInterval;
}
function BarHover({ x, y, width, height, top, left, right, bottom }: any) {
@@ -32,11 +31,8 @@ function BarHover({ x, y, width, height, top, left, right, bottom }: any) {
);
}
export function ReportHistogramChart({
interval,
data,
}: ReportHistogramChartProps) {
const { editMode, previous } = useChartContext();
export function ReportHistogramChart({ data }: ReportHistogramChartProps) {
const { editMode, previous, interval } = useChartContext();
const formatDate = useFormatDateInterval(interval);
const { series, setVisibleSeries } = useVisibleSeries(data);
const rechartData = useRechartDataModel(series);
@@ -71,11 +67,11 @@ export function ReportHistogramChart({
/>
{series.map((serie) => {
return (
<React.Fragment key={serie.name}>
<React.Fragment key={serie.id}>
{previous && (
<Bar
key={`${serie.name}:prev`}
name={`${serie.name}:prev`}
key={`${serie.id}:prev`}
name={`${serie.id}:prev`}
dataKey={`${serie.id}:prev:count`}
fill={getChartColor(serie.index)}
fillOpacity={0.2}
@@ -83,8 +79,8 @@ export function ReportHistogramChart({
/>
)}
<Bar
key={serie.name}
name={serie.name}
key={serie.id}
name={serie.id}
dataKey={`${serie.id}:count`}
fill={getChartColor(serie.index)}
radius={3}

View File

@@ -1,15 +1,20 @@
'use client';
import React from 'react';
import React, { useCallback, useMemo } 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 { getChartColor } from '@/utils/theme';
import { isSameDay, isSameHour, isSameMonth } from 'date-fns';
import { SplineIcon } from 'lucide-react';
import { last, pathOr } from 'ramda';
import {
Area,
CartesianGrid,
ComposedChart,
Legend,
Line,
LineChart,
@@ -27,49 +32,35 @@ 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;
references: IServiceReference[];
interval: IInterval;
lineType: IChartLineType;
}
function CustomLegend(props: {
payload?: { value: string; payload: { fill: string } }[];
}) {
if (!props.payload) {
return null;
}
return (
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1">
{props.payload
.filter((entry) => !entry.value.includes('noTooltip'))
.filter((entry) => !entry.value.includes(':prev'))
.map((entry) => (
<div className="flex gap-1" key={entry.value}>
<SplineIcon size={12} color={entry.payload.fill} />
<div
style={{
color: entry.payload.fill,
}}
>
{entry.value}
</div>
</div>
))}
</div>
export function ReportLineChart({ data }: ReportLineChartProps) {
const {
editMode,
previous,
interval,
projectId,
startDate,
endDate,
range,
lineType,
} = useChartContext();
const references = api.reference.getChartReferences.useQuery(
{
projectId,
startDate,
endDate,
range,
},
{
staleTime: 1000 * 60 * 5,
}
);
}
export function ReportLineChart({
lineType,
interval,
data,
references,
}: ReportLineChartProps) {
const { editMode, previous } = useChartContext();
const formatDate = useFormatDateInterval(interval);
const { series, setVisibleSeries } = useVisibleSeries(data);
const rechartData = useRechartDataModel(series);
@@ -96,20 +87,56 @@ export function ReportLineChart({
</linearGradient>
);
const useDashedLastLine = (series[0]?.data?.length || 0) > 2;
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 (
<>
<ResponsiveContainer>
{({ width, height }) => (
<LineChart width={width} height={height} data={rechartData}>
<ComposedChart width={width} height={height} data={rechartData}>
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}
vertical={false}
className="stroke-def-200"
/>
{references.map((ref) => (
{references.data?.map((ref) => (
<ReferenceLine
key={ref.id}
x={ref.date.getTime()}
@@ -150,18 +177,39 @@ export function ReportLineChart({
tickLine={false}
/>
{series.map((serie) => {
const color = getChartColor(serie.index);
return (
<React.Fragment key={serie.name}>
<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)',
getChartColor(serie.index),
color,
lastIntervalPercent
)}
{gradientTwoColors(
`hideJustLastInterval_${serie.id}`,
getChartColor(serie.index),
color,
'rgba(0,0,0,0)',
lastIntervalPercent
)}
@@ -169,24 +217,30 @@ export function ReportLineChart({
<Line
dot={false}
type={lineType}
name={serie.name}
name={serie.id}
isAnimationActive={false}
strokeWidth={2}
dataKey={`${serie.id}:count`}
stroke={
useDashedLastLine
? 'transparent'
: getChartColor(serie.index)
}
stroke={useDashedLastLine ? 'transparent' : color}
// Use for legend
fill={getChartColor(serie.index)}
fill={color}
/>
{isAreaStyle && (
<Area
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.name}:dashed:noTooltip`}
name={`${serie.id}:dashed:noTooltip`}
isAnimationActive={false}
strokeWidth={2}
dataKey={`${serie.id}:count`}
@@ -197,7 +251,7 @@ export function ReportLineChart({
<Line
dot={false}
type={lineType}
name={`${serie.name}:solid:noTooltip`}
name={`${serie.id}:solid:noTooltip`}
isAnimationActive={false}
strokeWidth={2}
dataKey={`${serie.id}:count`}
@@ -208,22 +262,22 @@ export function ReportLineChart({
{previous && (
<Line
type={lineType}
name={`${serie.name}:prev`}
name={`${serie.id}:prev`}
isAnimationActive={false}
strokeWidth={1}
dot={false}
strokeDasharray={'1 1'}
strokeOpacity={0.5}
dataKey={`${serie.id}:prev:count`}
stroke={getChartColor(serie.index)}
stroke={color}
// Use for legend
fill={getChartColor(serie.index)}
fill={color}
/>
)}
</React.Fragment>
);
})}
</LineChart>
</ComposedChart>
)}
</ResponsiveContainer>
{editMode && (

View File

@@ -18,7 +18,7 @@ export function ReportMapChart({ data }: ReportMapChartProps) {
const mapData = useMemo(
() =>
series.map((s) => ({
country: s.name.toLowerCase(),
country: s.names[0]?.toLowerCase() ?? '',
value: s.metrics[metric],
})),
[series, metric]

View File

@@ -24,7 +24,7 @@ export function ReportMetricChart({ data }: ReportMetricChartProps) {
{series.map((serie) => {
return (
<MetricCard
key={serie.name}
key={serie.id}
serie={serie}
metric={metric}
unit={unit}

View File

@@ -24,7 +24,7 @@ export function ReportPieChart({ data }: ReportPieChartProps) {
id: serie.id,
color: getChartColor(serie.index),
index: serie.index,
name: serie.name,
name: serie.names.join(' > '),
count: serie.metrics.sum,
percent: serie.metrics.sum / sum,
}));

View File

@@ -13,16 +13,19 @@ import {
import {
Tooltip,
TooltipContent,
Tooltiper,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useMappings } from '@/hooks/useMappings';
import { useNumber } from '@/hooks/useNumerFormatter';
import { useSelector } from '@/redux';
import { getPropertyLabel } from '@/translations/properties';
import type { IChartData } from '@/trpc/client';
import { getChartColor } from '@/utils/theme';
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
import { SerieName } from './SerieName';
interface ReportTableProps {
data: IChartData;
@@ -40,8 +43,8 @@ export function ReportTable({
const { setPage, paginate, page } = usePagination(ROWS_LIMIT);
const number = useNumber();
const interval = useSelector((state) => state.report.interval);
const breakdowns = useSelector((state) => state.report.breakdowns);
const formatDate = useFormatDateInterval(interval);
const getLabel = useMappings();
function handleChange(name: string, checked: boolean) {
setVisibleSeries((prev) => {
@@ -55,49 +58,61 @@ export function ReportTable({
return (
<>
<div className="grid grid-cols-[200px_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">
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
{breakdowns.length === 0 && <TableHead>Name</TableHead>}
{breakdowns.map((breakdown) => (
<TableHead key={breakdown.name}>
{getPropertyLabel(breakdown.name)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
<TableBody className="bg-def-100">
{paginate(data.series).map((serie, index) => {
const checked = !!visibleSeries.find(
(item) => item.name === serie.name
(item) => item.id === serie.id
);
return (
<TableRow key={serie.name}>
<TableCell className="h-10">
<div className="flex items-center gap-2">
<Checkbox
onCheckedChange={(checked) =>
handleChange(serie.name, !!checked)
}
style={
checked
? {
background: getChartColor(index),
borderColor: getChartColor(index),
}
: undefined
}
checked={checked}
/>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<div className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
{getLabel(serie.name)}
</div>
</TooltipTrigger>
<TooltipContent>
<p>{getLabel(serie.name)}</p>
</TooltipContent>
</Tooltip>
</div>
</TableCell>
<TableRow key={serie.id}>
{serie.names.map((name, nameIndex) => {
return (
<TableCell className="h-10" key={name}>
<div className="flex items-center gap-2">
{nameIndex === 0 ? (
<>
<Checkbox
onCheckedChange={(checked) =>
handleChange(serie.id, !!checked)
}
style={
checked
? {
background: getChartColor(index),
borderColor: getChartColor(index),
}
: undefined
}
checked={checked}
/>
<Tooltiper
side="left"
sideOffset={30}
content={<SerieName name={serie.names} />}
>
{name}
</Tooltiper>
</>
) : (
<SerieName name={name} />
)}
</div>
</TableCell>
);
})}
</TableRow>
);
})}
@@ -122,7 +137,7 @@ export function ReportTable({
<TableBody>
{paginate(data.series).map((serie) => {
return (
<TableRow key={serie.name}>
<TableRow key={serie.id}>
<TableCell className="h-10">
<div className="flex items-center gap-2 font-medium">
{number.format(serie.metrics.sum)}

View File

@@ -4,7 +4,7 @@ const createFlagIcon = (url: string) => {
return function (_props: LucideProps) {
return (
<span
className={`fi fis !block overflow-hidden rounded-full !leading-[1rem] fi-${url}`}
className={`fi !block aspect-[1.33] overflow-hidden rounded-[2px] fi-${url}`}
></span>
);
} as LucideIcon;

View File

@@ -13,6 +13,7 @@ import {
SearchIcon,
SmartphoneIcon,
TabletIcon,
TvIcon,
} from 'lucide-react';
import { NOT_SET_VALUE } from '@openpanel/constants';
@@ -20,9 +21,9 @@ import { NOT_SET_VALUE } from '@openpanel/constants';
import flags from './SerieIcon.flags';
import iconsWithUrls from './SerieIcon.urls';
interface SerieIconProps extends LucideProps {
name?: string;
}
type SerieIconProps = Omit<LucideProps, 'name'> & {
name?: string | string[];
};
function getProxyImage(url: string) {
return `${String(process.env.NEXT_PUBLIC_API_URL)}/misc/favicon?url=${encodeURIComponent(url)}`;
@@ -30,7 +31,7 @@ function getProxyImage(url: string) {
const createImageIcon = (url: string) => {
return function (_props: LucideProps) {
return <img className="h-4 rounded-[2px] object-contain" src={url} />;
return <img className="max-h-4 rounded-[2px] object-contain" src={url} />;
} as LucideIcon;
};
@@ -42,6 +43,7 @@ const mapper: Record<string, LucideIcon> = {
link_out: ExternalLinkIcon,
// Misc
smarttv: TvIcon,
mobile: SmartphoneIcon,
desktop: MonitorIcon,
tablet: TabletIcon,
@@ -64,7 +66,8 @@ const mapper: Record<string, LucideIcon> = {
...flags,
};
export function SerieIcon({ name, ...props }: SerieIconProps) {
export function SerieIcon({ name: names, ...props }: SerieIconProps) {
const name = Array.isArray(names) ? names[0] : names;
const Icon = useMemo(() => {
if (!name) {
return null;
@@ -80,6 +83,7 @@ export function SerieIcon({ name, ...props }: SerieIconProps) {
return createImageIcon(getProxyImage(name));
}
// Matching image file name
if (name.match(/(.+)\.\w{2,3}$/)) {
return createImageIcon(getProxyImage(`https://${name}`));
}
@@ -88,8 +92,8 @@ export function SerieIcon({ name, ...props }: SerieIconProps) {
}, [name]);
return Icon ? (
<div className="[&_a]:![&_a]:!h-4 relative h-4 flex-shrink-0 [&_svg]:!rounded-[2px]">
<Icon size={16} {...props} />
<div className="relative max-h-4 flex-shrink-0 [&_svg]:!rounded-[2px]">
<Icon size={16} {...props} name={name} />
</div>
) : null;
}

View File

@@ -8,9 +8,18 @@ const data = {
'vstat.info': 'https://vstat.info',
'yahoo!': 'https://yahoo.com',
android: 'https://image.similarpng.com/very-thumbnail/2020/08/Android-icon-on-transparent--background-PNG.png',
'android browser': 'https://image.similarpng.com/very-thumbnail/2020/08/Android-icon-on-transparent--background-PNG.png',
'silk': 'https://m.media-amazon.com/images/I/51VCjQCvF0L.png',
'kakaotalk': 'https://www.kakaocorp.com/',
bing: 'https://bing.com',
'electron': 'https://www.electronjs.org',
'whale': 'https://whale.naver.com',
'wechat': 'https://wechat.com',
chrome: 'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg',
'chrome webview': 'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg',
'chrome headless': 'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg',
chromecast: 'https://upload.wikimedia.org/wikipedia/commons/archive/2/26/20161130022732%21Chromecast_cast_button_icon.svg',
webkit: 'https://webkit.org',
duckduckgo: 'https://duckduckgo.com',
ecosia: 'https://ecosia.com',
edge: 'https://upload.wikimedia.org/wikipedia/commons/7/7e/Microsoft_Edge_logo_%282019%29.png',
@@ -19,6 +28,7 @@ const data = {
github: 'https://github.com',
gmail: 'https://mail.google.com',
google: 'https://google.com',
gsa: 'https://google.com', // Google Search App
instagram: 'https://instagram.com',
ios: 'https://cdn0.iconfinder.com/data/icons/flat-round-system/512/apple-1024.png',
linkedin: 'https://linkedin.com',

View File

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

View File

@@ -1,22 +1,47 @@
'use client';
import { Suspense, useEffect, useState } from 'react';
import type { IChartProps } from '@openpanel/validation';
import { Funnel } from '../funnel';
import { Chart } from './Chart';
import { withChartProivder } from './ChartProvider';
import { ChartLoading } from './ChartLoading';
import type { IChartContextType } from './ChartProvider';
import { ChartProvider } from './ChartProvider';
import { MetricCardLoading } from './MetricCard';
export const ChartSwitch = withChartProivder(function ChartSwitch(
props: IChartProps
) {
if (props.chartType === 'funnel') {
return <Funnel {...props} />;
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 />
);
}
return <Chart {...props} />;
});
return (
<Suspense
fallback={
props.chartType === 'metric' ? <MetricCardLoading /> : <ChartLoading />
}
>
<ChartProvider {...props}>
{props.chartType === 'funnel' ? <Funnel /> : <Chart />}
</ChartProvider>
</Suspense>
);
}
interface ChartSwitchShortcutProps {
interface ChartRootShortcutProps {
projectId: IChartProps['projectId'];
range?: IChartProps['range'];
previous?: IChartProps['previous'];
@@ -25,16 +50,16 @@ interface ChartSwitchShortcutProps {
events: IChartProps['events'];
}
export const ChartSwitchShortcut = ({
export const ChartRootShortcut = ({
projectId,
range = '7d',
previous = false,
chartType = 'linear',
interval = 'day',
events,
}: ChartSwitchShortcutProps) => {
}: ChartRootShortcutProps) => {
return (
<ChartSwitch
<ChartRoot
projectId={projectId}
range={range}
breakdowns={[]}

View File

@@ -2,19 +2,15 @@
import { api } from '@/trpc/client';
import type { IChartInput, IChartProps } from '@openpanel/validation';
import type { IChartInput } from '@openpanel/validation';
import { ChartEmpty } from '../chart/ChartEmpty';
import { withChartProivder } from '../chart/ChartProvider';
import { useChartContext } from '../chart/ChartProvider';
import { FunnelSteps } from './Funnel';
export type ReportChartProps = IChartProps;
export function Funnel() {
const { events, range, projectId } = useChartContext();
export const Funnel = withChartProivder(function Chart({
events,
range,
projectId,
}: ReportChartProps) {
const input: IChartInput = {
events,
range,
@@ -38,4 +34,4 @@ export const Funnel = withChartProivder(function Chart({
<FunnelSteps {...data} input={input} />
</div>
);
});
}