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

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

@@ -0,0 +1,92 @@
import { useNumber } from '@/hooks/useNumerFormatter';
import { cn } from '@/utils/cn';
import { ArrowDownIcon, ArrowUpIcon } from 'lucide-react';
import { useReportChartContext } from '../context';
export function getDiffIndicator<A, B, C>(
inverted: boolean | undefined,
state: string | undefined | null,
positive: A,
negative: B,
neutral: C
): A | B | C {
if (state === 'neutral' || !state) {
return neutral;
}
if (inverted === true) {
return state === 'positive' ? negative : positive;
}
return state === 'positive' ? positive : negative;
}
// TODO: Fix this mess!
interface PreviousDiffIndicatorProps {
diff?: number | null | undefined;
state?: string | null | undefined;
children?: React.ReactNode;
inverted?: boolean;
className?: string;
size?: 'sm' | 'lg';
}
export function PreviousDiffIndicator({
diff,
state,
inverted,
size = 'sm',
children,
className,
}: PreviousDiffIndicatorProps) {
const {
report: { previousIndicatorInverted, previous },
} = useReportChartContext();
const variant = getDiffIndicator(
inverted ?? previousIndicatorInverted,
state,
'bg-emerald-300',
'bg-rose-300',
undefined
);
const number = useNumber();
if (diff === null || diff === undefined || previous === false) {
return children ?? null;
}
const renderIcon = () => {
if (state === 'positive') {
return <ArrowUpIcon strokeWidth={3} size={10} color="#000" />;
}
if (state === 'negative') {
return <ArrowDownIcon strokeWidth={3} size={10} color="#000" />;
}
return null;
};
return (
<>
<div
className={cn(
'flex items-center gap-1 font-mono font-medium',
size === 'lg' && 'gap-2',
className
)}
>
<div
className={cn(
`flex size-4 items-center justify-center rounded-full`,
variant,
size === 'lg' && 'size-8'
)}
>
{renderIcon()}
</div>
{number.format(diff)}%
</div>
{children}
</>
);
}

View File

@@ -0,0 +1,149 @@
import React, { useEffect, useState } from 'react';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useNumber } from '@/hooks/useNumerFormatter';
import type { IRechartPayloadItem } from '@/hooks/useRechartDataModel';
import type { IToolTipProps } from '@/types';
import * as Portal from '@radix-ui/react-portal';
import { bind } from 'bind-event-listener';
import throttle from 'lodash.throttle';
import { useReportChartContext } from '../context';
import { PreviousDiffIndicator } from './previous-diff-indicator';
import { SerieIcon } from './serie-icon';
import { SerieName } from './serie-name';
type ReportLineChartTooltipProps = IToolTipProps<{
value: number;
name: string;
dataKey: string;
payload: Record<string, unknown>;
}>;
export function ReportChartTooltip({
active,
payload,
}: ReportLineChartTooltipProps) {
const {
report: { interval, unit },
} = useReportChartContext();
const formatDate = useFormatDateInterval(interval);
const number = useNumber();
const [position, setPosition] = useState<{ x: number; y: number } | null>(
null
);
const inactive = !active || !payload?.length;
useEffect(() => {
const setPositionThrottled = throttle(setPosition, 50);
const unsubMouseMove = bind(window, {
type: 'mousemove',
listener(event) {
if (!inactive) {
setPositionThrottled({ x: event.clientX, y: event.clientY + 20 });
}
},
});
const unsubDragEnter = bind(window, {
type: 'pointerdown',
listener() {
setPosition(null);
},
});
return () => {
unsubMouseMove();
unsubDragEnter();
};
}, [inactive]);
if (inactive) {
return null;
}
const limit = 3;
const sorted = payload
.slice(0)
.filter((item) => !item.dataKey.includes(':prev:count'))
.filter((item) => !item.name.includes(':noTooltip'))
.sort((a, b) => b.value - a.value);
const visible = sorted.slice(0, limit);
const hidden = sorted.slice(limit);
const correctXPosition = (x: number | undefined) => {
if (!x) {
return undefined;
}
const tooltipWidth = 300;
const screenWidth = window.innerWidth;
const newX = x;
if (newX + tooltipWidth > screenWidth) {
return screenWidth - tooltipWidth;
}
return newX;
};
return (
<Portal.Portal
style={{
position: 'fixed',
top: position?.y,
left: correctXPosition(position?.x),
zIndex: 1000,
}}
>
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
{visible.map((item, index) => {
// If we have a <Cell /> component, payload can be nested
const payload = item.payload.payload ?? item.payload;
const data = (
item.dataKey.includes(':')
? // @ts-expect-error
payload[`${item.dataKey.split(':')[0]}:payload`]
: payload
) as IRechartPayloadItem;
return (
<React.Fragment key={data.id}>
{index === 0 && data.date && (
<div className="flex justify-between gap-8">
<div>{formatDate(new Date(data.date))}</div>
</div>
)}
<div className="flex gap-2">
<div
className="w-[3px] rounded-full"
style={{ background: data.color }}
/>
<div className="col flex-1 gap-1">
<div className="flex items-center gap-1">
<SerieIcon name={data.names} />
<SerieName name={data.names} />
</div>
<div className="flex justify-between gap-8 font-mono font-medium">
<div className="row gap-1">
{number.formatWithUnit(data.count, unit)}
{!!data.previous && (
<span className="text-muted-foreground">
({number.formatWithUnit(data.previous.value, unit)})
</span>
)}
</div>
<PreviousDiffIndicator {...data.previous} />
</div>
</div>
</div>
</React.Fragment>
);
})}
{hidden.length > 0 && (
<div className="text-muted-foreground">
and {hidden.length} more...
</div>
)}
</div>
</Portal.Portal>
);
}

View File

@@ -0,0 +1,190 @@
import * as React from 'react';
import { Pagination, usePagination } from '@/components/pagination';
import { Stats, StatsCard } from '@/components/stats';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Tooltiper } from '@/components/ui/tooltip';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
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 './previous-diff-indicator';
import { SerieName } from './serie-name';
interface ReportTableProps {
data: IChartData;
visibleSeries: IChartData['series'];
setVisibleSeries: React.Dispatch<React.SetStateAction<string[]>>;
}
const ROWS_LIMIT = 50;
export function ReportTable({
data,
visibleSeries,
setVisibleSeries,
}: ReportTableProps) {
const { setPage, paginate, page } = usePagination(ROWS_LIMIT);
const number = useNumber();
const interval = useSelector((state) => state.report.interval);
const breakdowns = useSelector((state) => state.report.breakdowns);
const formatDate = useFormatDateInterval(interval);
function handleChange(name: string, checked: boolean) {
setVisibleSeries((prev) => {
if (checked) {
return [...prev, name];
} else {
return prev.filter((item) => item !== name);
}
});
}
return (
<>
<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">
<Table className="rounded-none border-b-0 border-l-0 border-t-0">
<TableHeader>
<TableRow>
{breakdowns.length === 0 && <TableHead>Name</TableHead>}
{breakdowns.map((breakdown) => (
<TableHead key={breakdown.name}>
{getPropertyLabel(breakdown.name)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody className="bg-def-100">
{paginate(data.series).map((serie, index) => {
const checked = !!visibleSeries.find(
(item) => item.id === serie.id
);
return (
<TableRow key={serie.id}>
{serie.names.map((name, nameIndex) => {
return (
<TableCell className="h-10" key={name}>
<div className="flex items-center gap-2">
{nameIndex === 0 ? (
<>
<Checkbox
onCheckedChange={(checked) =>
handleChange(serie.id, !!checked)
}
style={
checked
? {
background: getChartColor(index),
borderColor: getChartColor(index),
}
: undefined
}
checked={checked}
/>
<Tooltiper
side="left"
sideOffset={30}
content={<SerieName name={serie.names} />}
>
{name}
</Tooltiper>
</>
) : (
<SerieName name={name} />
)}
</div>
</TableCell>
);
})}
</TableRow>
);
})}
</TableBody>
</Table>
<div className="overflow-auto">
<Table className="rounded-none border-none">
<TableHeader>
<TableRow>
<TableHead>Total</TableHead>
<TableHead>Average</TableHead>
{data.series[0]?.data.map((serie) => (
<TableHead
key={serie.date.toString()}
className="whitespace-nowrap"
>
{formatDate(serie.date)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{paginate(data.series).map((serie) => {
return (
<TableRow key={serie.id}>
<TableCell className="h-10">
<div className="flex items-center gap-2 font-medium">
{number.format(serie.metrics.sum)}
<PreviousDiffIndicator
{...serie.metrics.previous?.sum}
/>
</div>
</TableCell>
<TableCell className="h-10">
<div className="flex items-center gap-2 font-medium">
{number.format(serie.metrics.average)}
<PreviousDiffIndicator
{...serie.metrics.previous?.average}
/>
</div>
</TableCell>
{serie.data.map((item) => {
return (
<TableCell className="h-10" key={item.date.toString()}>
<div className="flex items-center gap-2">
{number.format(item.count)}
<PreviousDiffIndicator {...item.previous} />
</div>
</TableCell>
);
})}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
<div className="row mt-4 justify-end">
<Pagination
cursor={page}
setCursor={setPage}
take={ROWS_LIMIT}
count={data.series.length}
/>
</div>
</>
);
}

View File

@@ -0,0 +1,190 @@
import type { LucideIcon, LucideProps } from 'lucide-react';
const createFlagIcon = (url: string) => {
return function (_props: LucideProps) {
return (
<span
className={`fi !block aspect-[1.33] overflow-hidden rounded-[2px] fi-${url}`}
></span>
);
} as LucideIcon;
};
const data = {
ie: createFlagIcon('ie'),
tw: createFlagIcon('tw'),
py: createFlagIcon('py'),
kr: createFlagIcon('kr'),
nz: createFlagIcon('nz'),
do: createFlagIcon('do'),
cl: createFlagIcon('cl'),
dz: createFlagIcon('dz'),
np: createFlagIcon('np'),
ma: createFlagIcon('ma'),
gh: createFlagIcon('gh'),
zm: createFlagIcon('zm'),
pa: createFlagIcon('pa'),
tn: createFlagIcon('tn'),
lk: createFlagIcon('lk'),
sv: createFlagIcon('sv'),
ve: createFlagIcon('ve'),
sn: createFlagIcon('sn'),
gt: createFlagIcon('gt'),
xk: createFlagIcon('xk'),
jm: createFlagIcon('jm'),
cm: createFlagIcon('cm'),
ni: createFlagIcon('ni'),
uy: createFlagIcon('uy'),
ss: createFlagIcon('ss'),
cd: createFlagIcon('cd'),
cu: createFlagIcon('cu'),
kh: createFlagIcon('kh'),
bb: createFlagIcon('bb'),
gf: createFlagIcon('gf'),
et: createFlagIcon('et'),
pe: createFlagIcon('pe'),
mo: createFlagIcon('mo'),
mn: createFlagIcon('mn'),
hn: createFlagIcon('hn'),
cn: createFlagIcon('cn'),
ng: createFlagIcon('ng'),
se: createFlagIcon('se'),
jp: createFlagIcon('jp'),
hk: createFlagIcon('hk'),
us: createFlagIcon('us'),
gb: createFlagIcon('gb'),
ua: createFlagIcon('ua'),
ru: createFlagIcon('ru'),
de: createFlagIcon('de'),
fr: createFlagIcon('fr'),
br: createFlagIcon('br'),
in: createFlagIcon('in'),
it: createFlagIcon('it'),
es: createFlagIcon('es'),
pl: createFlagIcon('pl'),
nl: createFlagIcon('nl'),
id: createFlagIcon('id'),
tr: createFlagIcon('tr'),
ph: createFlagIcon('ph'),
ca: createFlagIcon('ca'),
ar: createFlagIcon('ar'),
mx: createFlagIcon('mx'),
za: createFlagIcon('za'),
au: createFlagIcon('au'),
co: createFlagIcon('co'),
ch: createFlagIcon('ch'),
at: createFlagIcon('at'),
be: createFlagIcon('be'),
pt: createFlagIcon('pt'),
my: createFlagIcon('my'),
th: createFlagIcon('th'),
vn: createFlagIcon('vn'),
sg: createFlagIcon('sg'),
eg: createFlagIcon('eg'),
sa: createFlagIcon('sa'),
pk: createFlagIcon('pk'),
bd: createFlagIcon('bd'),
ro: createFlagIcon('ro'),
hu: createFlagIcon('hu'),
cz: createFlagIcon('cz'),
gr: createFlagIcon('gr'),
il: createFlagIcon('il'),
no: createFlagIcon('no'),
fi: createFlagIcon('fi'),
dk: createFlagIcon('dk'),
sk: createFlagIcon('sk'),
bg: createFlagIcon('bg'),
hr: createFlagIcon('hr'),
rs: createFlagIcon('rs'),
ba: createFlagIcon('ba'),
si: createFlagIcon('si'),
lv: createFlagIcon('lv'),
lt: createFlagIcon('lt'),
ee: createFlagIcon('ee'),
by: createFlagIcon('by'),
md: createFlagIcon('md'),
kz: createFlagIcon('kz'),
uz: createFlagIcon('uz'),
kg: createFlagIcon('kg'),
tj: createFlagIcon('tj'),
tm: createFlagIcon('tm'),
az: createFlagIcon('az'),
ge: createFlagIcon('ge'),
am: createFlagIcon('am'),
af: createFlagIcon('af'),
ir: createFlagIcon('ir'),
iq: createFlagIcon('iq'),
sy: createFlagIcon('sy'),
lb: createFlagIcon('lb'),
jo: createFlagIcon('jo'),
ps: createFlagIcon('ps'),
kw: createFlagIcon('kw'),
qa: createFlagIcon('qa'),
om: createFlagIcon('om'),
ye: createFlagIcon('ye'),
ae: createFlagIcon('ae'),
bh: createFlagIcon('bh'),
cy: createFlagIcon('cy'),
mt: createFlagIcon('mt'),
sm: createFlagIcon('sm'),
li: createFlagIcon('li'),
is: createFlagIcon('is'),
al: createFlagIcon('al'),
mk: createFlagIcon('mk'),
me: createFlagIcon('me'),
ad: createFlagIcon('ad'),
lu: createFlagIcon('lu'),
mc: createFlagIcon('mc'),
fo: createFlagIcon('fo'),
gg: createFlagIcon('gg'),
je: createFlagIcon('je'),
im: createFlagIcon('im'),
gi: createFlagIcon('gi'),
va: createFlagIcon('va'),
ax: createFlagIcon('ax'),
bl: createFlagIcon('bl'),
mf: createFlagIcon('mf'),
pm: createFlagIcon('pm'),
yt: createFlagIcon('yt'),
wf: createFlagIcon('wf'),
tf: createFlagIcon('tf'),
re: createFlagIcon('re'),
sc: createFlagIcon('sc'),
mu: createFlagIcon('mu'),
zw: createFlagIcon('zw'),
mz: createFlagIcon('mz'),
na: createFlagIcon('na'),
bw: createFlagIcon('bw'),
ls: createFlagIcon('ls'),
sz: createFlagIcon('sz'),
bi: createFlagIcon('bi'),
rw: createFlagIcon('rw'),
ug: createFlagIcon('ug'),
ke: createFlagIcon('ke'),
tz: createFlagIcon('tz'),
mg: createFlagIcon('mg'),
cr: createFlagIcon('cr'),
ky: createFlagIcon('ky'),
gy: createFlagIcon('gy'),
mm: createFlagIcon('mm'),
la: createFlagIcon('la'),
gl: createFlagIcon('gl'),
gp: createFlagIcon('gp'),
fj: createFlagIcon('fj'),
cv: createFlagIcon('cv'),
gn: createFlagIcon('gn'),
bj: createFlagIcon('bj'),
bo: createFlagIcon('bo'),
bq: createFlagIcon('bq'),
bs: createFlagIcon('bs'),
ly: createFlagIcon('ly'),
bn: createFlagIcon('bn'),
tt: createFlagIcon('tt'),
sr: createFlagIcon('sr'),
ec: createFlagIcon('ec'),
mv: createFlagIcon('mv'),
pr: createFlagIcon('pr'),
ci: createFlagIcon('ci'),
};
export default data;

View File

@@ -0,0 +1,99 @@
import { useMemo } from 'react';
import type { LucideIcon, LucideProps } from 'lucide-react';
import {
ActivityIcon,
ExternalLinkIcon,
HelpCircleIcon,
MailIcon,
MessageCircleIcon,
MonitorIcon,
MonitorPlayIcon,
PodcastIcon,
ScanIcon,
SearchIcon,
SmartphoneIcon,
TabletIcon,
TvIcon,
} from 'lucide-react';
import { NOT_SET_VALUE } from '@openpanel/constants';
import flags from './serie-icon.flags';
import iconsWithUrls from './serie-icon.urls';
type SerieIconProps = Omit<LucideProps, 'name'> & {
name?: string | string[];
};
function getProxyImage(url: string) {
return `${String(process.env.NEXT_PUBLIC_API_URL)}/misc/favicon?url=${encodeURIComponent(url)}`;
}
const createImageIcon = (url: string) => {
return function (_props: LucideProps) {
return <img className="max-h-4 rounded-[2px] object-contain" src={url} />;
} as LucideIcon;
};
const mapper: Record<string, LucideIcon> = {
// Events
screen_view: MonitorPlayIcon,
session_start: ActivityIcon,
session_end: ActivityIcon,
link_out: ExternalLinkIcon,
// Misc
smarttv: TvIcon,
mobile: SmartphoneIcon,
desktop: MonitorIcon,
tablet: TabletIcon,
search: SearchIcon,
social: PodcastIcon,
email: MailIcon,
podcast: PodcastIcon,
comment: MessageCircleIcon,
unknown: HelpCircleIcon,
[NOT_SET_VALUE]: ScanIcon,
...Object.entries(iconsWithUrls).reduce(
(acc, [key, value]) => ({
...acc,
[key]: createImageIcon(getProxyImage(value)),
}),
{}
),
...flags,
};
export function SerieIcon({ name: names, ...props }: SerieIconProps) {
const name = Array.isArray(names) ? names[0] : names;
const Icon = useMemo(() => {
if (!name) {
return null;
}
const mapped = mapper[name.toLowerCase()] ?? null;
if (mapped) {
return mapped;
}
if (name.includes('http')) {
return createImageIcon(getProxyImage(name));
}
// Matching image file name
if (name.match(/(.+)\.\w{2,3}$/)) {
return createImageIcon(getProxyImage(`https://${name}`));
}
return null;
}, [name]);
return Icon ? (
<div className="relative max-h-4 flex-shrink-0 [&_svg]:!rounded-[2px]">
<Icon size={16} {...props} name={name} />
</div>
) : null;
}

View File

@@ -0,0 +1,78 @@
// prettier-ignore
const data = {
'chromium os': 'https://upload.wikimedia.org/wikipedia/commons/2/28/Chromium_Logo.svg',
'mac os': 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/30/MacOS_logo.svg/1200px-MacOS_logo.svg.png',
'apple': 'https://sladesportfolio.wordpress.com/wp-content/uploads/2015/08/apple_logo_black-svg.png',
'huawei': 'https://upload.wikimedia.org/wikipedia/en/0/04/Huawei_Standard_logo.svg',
'xiaomi': 'https://upload.wikimedia.org/wikipedia/commons/2/29/Xiaomi_logo.svg',
'sony': 'https://serialtrainer7.com/wp-content/uploads/2021/07/sony-logo-300px-square.png',
'lg': 'https://upload.wikimedia.org/wikipedia/commons/2/20/LG_symbol.svg',
'samsung': 'https://seekvectors.com/storage/images/Samsung-Logo-22.svg',
'oppo': 'https://indoleads.nyc3.cdn.digitaloceanspaces.com/uploads/offers/logos/8695_95411e367b832.png',
'motorola': 'https://upload.wikimedia.org/wikipedia/commons/8/8f/Motorola_M_symbol_blue.svg',
'oneplus': 'https://pbs.twimg.com/profile_images/1709165009148809216/ebHb4xhF_400x400.png',
'asus': 'https://cdn-icons-png.freepik.com/512/5969/5969050.png',
'fairphone': 'https://cdn.dribbble.com/users/433772/screenshots/2109827/fairphone_dribbble.jpg',
'nokia': 'https://www.gizchina.com/wp-content/uploads/images/2023/02/Nokia-logo.webp',
'mobile safari': 'https://upload.wikimedia.org/wikipedia/commons/5/52/Safari_browser_logo.svg',
'openpanel.dev': 'https://openpanel.dev',
'samsung internet': 'https://upload.wikimedia.org/wikipedia/commons/thumb/e/e9/Samsung_Internet_logo.svg/1024px-Samsung_Internet_logo.svg.png',
'vstat.info': 'https://vstat.info',
'yahoo!': 'https://yahoo.com',
android: 'https://image.similarpng.com/very-thumbnail/2020/08/Android-icon-on-transparent--background-PNG.png',
'android browser': 'https://image.similarpng.com/very-thumbnail/2020/08/Android-icon-on-transparent--background-PNG.png',
'silk': 'https://m.media-amazon.com/images/I/51VCjQCvF0L.png',
'kakaotalk': 'https://www.kakaocorp.com/',
bing: 'https://bing.com',
'electron': 'https://www.electronjs.org',
'whale': 'https://whale.naver.com',
'wechat': 'https://wechat.com',
chrome: 'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg',
'chrome webview': 'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg',
'chrome headless': 'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg',
chromecast: 'https://upload.wikimedia.org/wikipedia/commons/archive/2/26/20161130022732%21Chromecast_cast_button_icon.svg',
webkit: 'https://webkit.org',
duckduckgo: 'https://duckduckgo.com',
ecosia: 'https://ecosia.com',
edge: 'https://upload.wikimedia.org/wikipedia/commons/7/7e/Microsoft_Edge_logo_%282019%29.png',
facebook: 'https://facebook.com',
firefox: 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a0/Firefox_logo%2C_2019.svg/1920px-Firefox_logo%2C_2019.svg.png',
github: 'https://github.com',
gmail: 'https://mail.google.com',
google: 'https://google.com',
gsa: 'https://google.com', // Google Search App
instagram: 'https://instagram.com',
ios: 'https://cdn0.iconfinder.com/data/icons/flat-round-system/512/apple-1024.png',
linkedin: 'https://linkedin.com',
linux: 'https://upload.wikimedia.org/wikipedia/commons/3/35/Tux.svg',
ubuntu: 'https://upload.wikimedia.org/wikipedia/commons/a/ab/Logo-ubuntu_cof-orange-hex.svg',
weibo: 'https://en.wikipedia.org/wiki/Weibo#/media/File:Sina_Weibo.svg',
microlaunch: 'https://microlaunch.net',
openalternative: 'https://openalternative.co',
opera: 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/49/Opera_2015_icon.svg/1920px-Opera_2015_icon.svg.png',
pinterest: 'https://www.pinterest.se',
producthunt: 'https://www.producthunt.com',
reddit: 'https://reddit.com',
safari: 'https://upload.wikimedia.org/wikipedia/commons/5/52/Safari_browser_logo.svg',
slack: 'https://slack.com',
snapchat: 'https://snapchat.com',
taaft: 'https://theresanaiforthat.com',
twitter: 'https://twitter.com',
windows: 'https://upload.wikimedia.org/wikipedia/commons/c/c7/Windows_logo_-_2012.png',
yandex: 'https://yandex.com',
youtube: 'https://youtube.com',
ossgallery: 'https://oss.gallery',
convertkit: 'https://convertkit.com',
whatsapp: 'https://www.whatsapp.com/',
telegram: 'https://telegram.org/',
tiktok: 'https://tiktok.com',
sharpspring: 'https://sharpspring.com',
'hacker news': 'https://news.ycombinator.com',
'betalist': 'https://betalist.com',
'qwant': 'https://www.qwant.com',
flipboard: 'https://flipboard.com/',
trustpilot: 'https://trustpilot.com',
'outlook.com': 'https://login.live.com/',
};
export default data;

View File

@@ -0,0 +1,43 @@
import { cn } from '@/utils/cn';
import { ChevronRightIcon } from 'lucide-react';
import { NOT_SET_VALUE } from '@openpanel/constants';
import { useReportChartContext } from '../context';
interface SerieNameProps {
name: string | string[];
className?: string;
}
export function SerieName({ name, className }: SerieNameProps) {
const {
options: { renderSerieName },
} = useReportChartContext();
if (Array.isArray(name)) {
if (renderSerieName) {
return 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 (renderSerieName) {
return renderSerieName([name]);
}
return <>{name}</>;
}