(focusLabel);
+
+ const chartData: any = useMemo(() => {
+ if (!data) return;
+
+ const map = (data as any[]).reduce((obj, { x, t, y }) => {
+ if (!obj[x]) {
+ obj[x] = [];
+ }
+
+ obj[x].push({ x: t, y });
+
+ return obj;
+ }, {});
+
+ if (!map || Object.keys(map).length === 0) {
+ return {
+ datasets: [
+ {
+ data: generateTimeSeries([], startDate, endDate, unit, dateLocale),
+ lineTension: 0,
+ borderWidth: 1,
+ },
+ ],
+ };
+ } else {
+ return {
+ datasets: Object.keys(map).map((key, index) => {
+ const color = colord(CHART_COLORS[index % CHART_COLORS.length]);
+ return {
+ label: key,
+ data: generateTimeSeries(map[key], startDate, endDate, unit, dateLocale),
+ lineTension: 0,
+ backgroundColor: color.alpha(0.6).toRgbString(),
+ borderColor: color.alpha(0.7).toRgbString(),
+ borderWidth: 1,
+ };
+ }),
+ focusLabel,
+ };
+ }
+ }, [data, startDate, endDate, unit, focusLabel]);
+
+ useEffect(() => {
+ if (label !== focusLabel) {
+ setLabel(focusLabel);
+ }
+ }, [focusLabel]);
+
+ const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]);
+
+ return (
+
+ {chartData && (
+
+ )}
+
+ );
+}
diff --git a/src/components/metrics/Legend.tsx b/src/components/metrics/Legend.tsx
new file mode 100644
index 0000000..34ddb5a
--- /dev/null
+++ b/src/components/metrics/Legend.tsx
@@ -0,0 +1,39 @@
+import { Row, StatusLight, Text } from '@umami/react-zen';
+import type { LegendItem } from 'chart.js/auto';
+import { colord } from 'colord';
+
+export function Legend({
+ items = [],
+ onClick,
+}: {
+ items: any[];
+ onClick: (index: LegendItem) => void;
+}) {
+ if (!items.find(({ text }) => text)) {
+ return null;
+ }
+
+ return (
+
+ {items.map(item => {
+ const { text, fillStyle, hidden } = item;
+ const color = colord(fillStyle);
+
+ return (
+ onClick(item)}>
+
+
+ {text}
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/metrics/ListTable.tsx b/src/components/metrics/ListTable.tsx
new file mode 100644
index 0000000..f233bfe
--- /dev/null
+++ b/src/components/metrics/ListTable.tsx
@@ -0,0 +1,152 @@
+import { config, useSpring } from '@react-spring/web';
+import { Column, Grid, Row, Text } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+import { FixedSizeList } from 'react-window';
+import { AnimatedDiv } from '@/components/common/AnimatedDiv';
+import { Empty } from '@/components/common/Empty';
+import { useMessages, useMobile } from '@/components/hooks';
+import { formatLongCurrency, formatLongNumber } from '@/lib/format';
+
+const ITEM_SIZE = 30;
+
+interface ListData {
+ label: string;
+ count: number;
+ percent: number;
+}
+
+export interface ListTableProps {
+ data?: ListData[];
+ title?: string;
+ metric?: string;
+ className?: string;
+ renderLabel?: (data: ListData, index: number) => ReactNode;
+ renderChange?: (data: ListData, index: number) => ReactNode;
+ animate?: boolean;
+ virtualize?: boolean;
+ showPercentage?: boolean;
+ itemCount?: number;
+ currency?: string;
+}
+
+export function ListTable({
+ data = [],
+ title,
+ metric,
+ renderLabel,
+ renderChange,
+ animate = true,
+ virtualize = false,
+ showPercentage = true,
+ itemCount = 10,
+ currency,
+}: ListTableProps) {
+ const { formatMessage, labels } = useMessages();
+ const { isPhone } = useMobile();
+
+ const getRow = (row: ListData, index: number) => {
+ const { label, count, percent } = row;
+
+ return (
+
+ );
+ };
+
+ const ListTableRow = ({ index, style }) => {
+ return {getRow(data[index], index)}
;
+ };
+
+ return (
+
+
+ {title}
+
+ {metric}
+
+
+
+ {data?.length === 0 && }
+ {virtualize && data.length > 0 ? (
+
+ {ListTableRow}
+
+ ) : (
+ data.map(getRow)
+ )}
+
+
+ );
+}
+
+const AnimatedRow = ({
+ label,
+ value = 0,
+ percent,
+ change,
+ animate,
+ showPercentage = true,
+ currency,
+ isPhone,
+}) => {
+ const props = useSpring({
+ width: percent,
+ y: !Number.isNaN(value) ? value : 0,
+ from: { width: 0, y: 0 },
+ config: animate ? config.default : { duration: 0 },
+ });
+
+ return (
+
+
+
+ {label}
+
+
+
+ {change}
+
+
+ {currency
+ ? props.y?.to(n => formatLongCurrency(n, currency))
+ : props.y?.to(formatLongNumber)}
+
+
+
+ {showPercentage && (
+
+ {props.width.to(n => `${n?.toFixed?.(0)}%`)}
+
+ )}
+
+ );
+};
diff --git a/src/components/metrics/MetricCard.tsx b/src/components/metrics/MetricCard.tsx
new file mode 100644
index 0000000..d15bcf1
--- /dev/null
+++ b/src/components/metrics/MetricCard.tsx
@@ -0,0 +1,56 @@
+import { useSpring } from '@react-spring/web';
+import { Column, Text } from '@umami/react-zen';
+import { AnimatedDiv } from '@/components/common/AnimatedDiv';
+import { ChangeLabel } from '@/components/metrics/ChangeLabel';
+import { formatNumber } from '@/lib/format';
+
+export interface MetricCardProps {
+ value: number;
+ previousValue?: number;
+ change?: number;
+ label?: string;
+ reverseColors?: boolean;
+ formatValue?: (n: any) => string;
+ showLabel?: boolean;
+ showChange?: boolean;
+}
+
+export const MetricCard = ({
+ value = 0,
+ change = 0,
+ label,
+ reverseColors = false,
+ formatValue = formatNumber,
+ showLabel = true,
+ showChange = false,
+}: MetricCardProps) => {
+ const diff = value - change;
+ const pct = ((value - diff) / diff) * 100;
+ const props = useSpring({ x: Number(value) || 0, from: { x: 0 } });
+ const changeProps = useSpring({ x: Number(pct) || 0, from: { x: 0 } });
+
+ return (
+
+ {showLabel && (
+
+ {label}
+
+ )}
+
+ {props?.x?.to(x => formatValue(x))}
+
+ {showChange && (
+
+ {changeProps?.x?.to(x => `${Math.abs(~~x)}%`)}
+
+ )}
+
+ );
+};
diff --git a/src/components/metrics/MetricLabel.tsx b/src/components/metrics/MetricLabel.tsx
new file mode 100644
index 0000000..31c331f
--- /dev/null
+++ b/src/components/metrics/MetricLabel.tsx
@@ -0,0 +1,142 @@
+import { Row } from '@umami/react-zen';
+import { Favicon } from '@/components/common/Favicon';
+import { FilterLink } from '@/components/common/FilterLink';
+import { TypeIcon } from '@/components/common/TypeIcon';
+import {
+ useCountryNames,
+ useFormat,
+ useLocale,
+ useMessages,
+ useRegionNames,
+} from '@/components/hooks';
+import { GROUPED_DOMAINS } from '@/lib/constants';
+
+export interface MetricLabelProps {
+ type: string;
+ data: any;
+ onClick?: () => void;
+}
+
+export function MetricLabel({ type, data }: MetricLabelProps) {
+ const { formatMessage, labels } = useMessages();
+ const { formatValue, formatCity } = useFormat();
+ const { locale } = useLocale();
+ const { countryNames } = useCountryNames(locale);
+ const { getRegionName } = useRegionNames(locale);
+
+ const { label, country, domain } = data;
+
+ switch (type) {
+ case 'browser':
+ case 'os':
+ return (
+ }
+ />
+ );
+
+ case 'channel':
+ return formatMessage(labels[label]);
+
+ case 'city':
+ return (
+
+ )
+ }
+ />
+ );
+
+ case 'region':
+ return (
+ }
+ />
+ );
+
+ case 'country':
+ return (
+ }
+ />
+ );
+
+ case 'path':
+ case 'entry':
+ case 'exit':
+ return (
+
+ );
+
+ case 'device':
+ return (
+ }
+ />
+ );
+
+ case 'referrer':
+ return (
+ }
+ />
+ );
+
+ case 'domain':
+ if (label === 'Other') {
+ return `(${formatMessage(labels.other)})`;
+ } else {
+ const name = GROUPED_DOMAINS.find(({ domain }) => domain === label)?.name;
+
+ if (!name) {
+ return null;
+ }
+
+ return (
+
+
+ {name}
+
+ );
+ }
+
+ case 'language':
+ return formatValue(label, 'language');
+
+ default:
+ return ;
+ }
+}
diff --git a/src/components/metrics/MetricsBar.tsx b/src/components/metrics/MetricsBar.tsx
new file mode 100644
index 0000000..850c6bc
--- /dev/null
+++ b/src/components/metrics/MetricsBar.tsx
@@ -0,0 +1,14 @@
+import { Grid, type GridProps } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+
+export interface MetricsBarProps extends GridProps {
+ children?: ReactNode;
+}
+
+export function MetricsBar({ children, ...props }: MetricsBarProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/metrics/MetricsExpandedTable.tsx b/src/components/metrics/MetricsExpandedTable.tsx
new file mode 100644
index 0000000..f24c952
--- /dev/null
+++ b/src/components/metrics/MetricsExpandedTable.tsx
@@ -0,0 +1,139 @@
+import { Button, Column, DataColumn, DataTable, Icon, Row, SearchField } from '@umami/react-zen';
+import { type ReactNode, useState } from 'react';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useMessages, useWebsiteExpandedMetricsQuery } from '@/components/hooks';
+import { X } from '@/components/icons';
+import { DownloadButton } from '@/components/input/DownloadButton';
+import { MetricLabel } from '@/components/metrics/MetricLabel';
+import { SESSION_COLUMNS } from '@/lib/constants';
+import { formatShortTime } from '@/lib/format';
+
+export interface MetricsExpandedTableProps {
+ websiteId: string;
+ type?: string;
+ title?: string;
+ dataFilter?: (data: any) => any;
+ onSearch?: (search: string) => void;
+ params?: { [key: string]: any };
+ allowSearch?: boolean;
+ allowDownload?: boolean;
+ renderLabel?: (row: any, index: number) => ReactNode;
+ onClose?: () => void;
+ children?: ReactNode;
+}
+
+export function MetricsExpandedTable({
+ websiteId,
+ type,
+ title,
+ params,
+ allowSearch = true,
+ allowDownload = true,
+ onClose,
+ children,
+}: MetricsExpandedTableProps) {
+ const [search, setSearch] = useState('');
+ const { formatMessage, labels } = useMessages();
+ const isType = ['browser', 'country', 'device', 'os'].includes(type);
+ const showBounceDuration = SESSION_COLUMNS.includes(type);
+
+ const { data, isLoading, isFetching, error } = useWebsiteExpandedMetricsQuery(websiteId, {
+ type,
+ search: isType ? undefined : search,
+ ...params,
+ });
+
+ const items = data?.map(({ name, ...props }) => ({ label: name, ...props }));
+
+ return (
+ <>
+
+ {allowSearch && }
+
+ {children}
+ {allowDownload && }
+ {onClose && (
+
+ )}
+
+
+
+
+ {items && (
+
+
+ {row => (
+
+
+
+ )}
+
+
+ {row => row?.visitors?.toLocaleString()}
+
+
+ {row => row?.visits?.toLocaleString()}
+
+
+ {row => row?.pageviews?.toLocaleString()}
+
+ {showBounceDuration && [
+
+ {row => {
+ const n = (Math.min(row?.visits, row?.bounces) / row?.visits) * 100;
+ return `${Math.round(+n)}%`;
+ }}
+ ,
+
+
+ {row => {
+ const n = row?.totaltime / row?.visits;
+ return `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`;
+ }}
+ ,
+ ]}
+
+ )}
+
+
+ >
+ );
+}
diff --git a/src/components/metrics/MetricsTable.tsx b/src/components/metrics/MetricsTable.tsx
new file mode 100644
index 0000000..e99bd21
--- /dev/null
+++ b/src/components/metrics/MetricsTable.tsx
@@ -0,0 +1,95 @@
+import { Grid, Icon, Row, Text } from '@umami/react-zen';
+import { useEffect, useMemo } from 'react';
+import { LinkButton } from '@/components/common/LinkButton';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useMessages, useNavigation, useWebsiteMetricsQuery } from '@/components/hooks';
+import { Maximize } from '@/components/icons';
+import { MetricLabel } from '@/components/metrics/MetricLabel';
+import { percentFilter } from '@/lib/filters';
+import { ListTable, type ListTableProps } from './ListTable';
+
+export interface MetricsTableProps extends ListTableProps {
+ websiteId: string;
+ type: string;
+ dataFilter?: (data: any) => any;
+ limit?: number;
+ showMore?: boolean;
+ filterLink?: boolean;
+ params?: Record;
+ onDataLoad?: (data: any) => void;
+}
+
+export function MetricsTable({
+ websiteId,
+ type,
+ dataFilter,
+ limit,
+ showMore = false,
+ filterLink = true,
+ params,
+ onDataLoad,
+ ...props
+}: MetricsTableProps) {
+ const { updateParams } = useNavigation();
+ const { formatMessage, labels } = useMessages();
+ const { data, isLoading, isFetching, error } = useWebsiteMetricsQuery(websiteId, {
+ type,
+ limit,
+ ...params,
+ });
+
+ const filteredData = useMemo(() => {
+ if (data) {
+ let items = data as any[];
+
+ if (dataFilter) {
+ if (Array.isArray(dataFilter)) {
+ items = dataFilter.reduce((arr, filter) => {
+ return filter(arr);
+ }, items);
+ } else {
+ items = dataFilter(items);
+ }
+ }
+
+ items = percentFilter(items);
+
+ return items.map(({ x, y, z, ...props }) => ({ label: x, count: y, percent: z, ...props }));
+ }
+ return [];
+ }, [data, dataFilter, limit, type]);
+
+ useEffect(() => {
+ if (data) {
+ onDataLoad?.(data);
+ }
+ }, [data]);
+
+ const renderLabel = (row: any) => {
+ return filterLink ? : row.label;
+ };
+
+ return (
+
+
+ {data && }
+ {showMore && limit && (
+
+
+
+
+
+ {formatMessage(labels.more)}
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/metrics/PageviewsChart.tsx b/src/components/metrics/PageviewsChart.tsx
new file mode 100644
index 0000000..b83f8dc
--- /dev/null
+++ b/src/components/metrics/PageviewsChart.tsx
@@ -0,0 +1,98 @@
+import { useTheme } from '@umami/react-zen';
+import { useCallback, useMemo } from 'react';
+import { BarChart, type BarChartProps } from '@/components/charts/BarChart';
+import { useLocale, useMessages } from '@/components/hooks';
+import { renderDateLabels } from '@/lib/charts';
+import { getThemeColors } from '@/lib/colors';
+import { generateTimeSeries } from '@/lib/date';
+
+export interface PageviewsChartProps extends BarChartProps {
+ data: {
+ pageviews: any[];
+ sessions: any[];
+ compare?: {
+ pageviews: any[];
+ sessions: any[];
+ };
+ };
+ unit: string;
+}
+
+export function PageviewsChart({ data, unit, minDate, maxDate, ...props }: PageviewsChartProps) {
+ const { formatMessage, labels } = useMessages();
+ const { theme } = useTheme();
+ const { locale, dateLocale } = useLocale();
+ const { colors } = useMemo(() => getThemeColors(theme), [theme]);
+
+ const chartData: any = useMemo(() => {
+ if (!data) return;
+
+ return {
+ __id: Date.now(),
+ datasets: [
+ {
+ type: 'bar',
+ label: formatMessage(labels.visitors),
+ data: generateTimeSeries(data.sessions, minDate, maxDate, unit, dateLocale),
+ borderWidth: 1,
+ barPercentage: 0.9,
+ categoryPercentage: 0.9,
+ ...colors.chart.visitors,
+ order: 3,
+ },
+ {
+ type: 'bar',
+ label: formatMessage(labels.views),
+ data: generateTimeSeries(data.pageviews, minDate, maxDate, unit, dateLocale),
+ barPercentage: 0.9,
+ categoryPercentage: 0.9,
+ borderWidth: 1,
+ ...colors.chart.views,
+ order: 4,
+ },
+ ...(data.compare
+ ? [
+ {
+ type: 'line',
+ label: `${formatMessage(labels.views)} (${formatMessage(labels.previous)})`,
+ data: generateTimeSeries(
+ data.compare.pageviews,
+ minDate,
+ maxDate,
+ unit,
+ dateLocale,
+ ),
+ borderWidth: 2,
+ backgroundColor: '#8601B0',
+ borderColor: '#8601B0',
+ order: 1,
+ },
+ {
+ type: 'line',
+ label: `${formatMessage(labels.visitors)} (${formatMessage(labels.previous)})`,
+ data: generateTimeSeries(data.compare.sessions, minDate, maxDate, unit, dateLocale),
+ borderWidth: 2,
+ backgroundColor: '#f15bb5',
+ borderColor: '#f15bb5',
+ order: 2,
+ },
+ ]
+ : []),
+ ],
+ };
+ }, [data, locale]);
+
+ const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]);
+
+ return (
+
+ );
+}
diff --git a/src/components/metrics/RealtimeChart.tsx b/src/components/metrics/RealtimeChart.tsx
new file mode 100644
index 0000000..f42b96d
--- /dev/null
+++ b/src/components/metrics/RealtimeChart.tsx
@@ -0,0 +1,59 @@
+import { isBefore, startOfMinute, subMinutes } from 'date-fns';
+import { useMemo, useRef } from 'react';
+import { useTimezone } from '@/components/hooks';
+import { DEFAULT_ANIMATION_DURATION, REALTIME_RANGE } from '@/lib/constants';
+import type { RealtimeData } from '@/lib/types';
+import { PageviewsChart } from './PageviewsChart';
+
+export interface RealtimeChartProps {
+ data: RealtimeData;
+ unit: string;
+ className?: string;
+}
+
+export function RealtimeChart({ data, unit, ...props }: RealtimeChartProps) {
+ const { formatSeriesTimezone, fromUtc, timezone } = useTimezone();
+ const endDate = startOfMinute(new Date());
+ const startDate = subMinutes(endDate, REALTIME_RANGE);
+ const prevEndDate = useRef(endDate);
+ const prevData = useRef(null);
+
+ const chartData = useMemo(() => {
+ if (!data) {
+ return { pageviews: [], sessions: [] };
+ }
+
+ return {
+ pageviews: formatSeriesTimezone(data.series.views, 'x', timezone),
+ sessions: formatSeriesTimezone(data.series.visitors, 'x', timezone),
+ };
+ }, [data, startDate, endDate, unit]);
+
+ const animationDuration = useMemo(() => {
+ // Don't animate the bars shifting over because it looks weird
+ if (isBefore(prevEndDate.current, endDate)) {
+ prevEndDate.current = endDate;
+ return 0;
+ }
+
+ // Don't animate when data hasn't changed
+ const serialized = JSON.stringify(chartData);
+ if (prevData.current === serialized) {
+ return 0;
+ }
+ prevData.current = serialized;
+
+ return DEFAULT_ANIMATION_DURATION;
+ }, [endDate, chartData]);
+
+ return (
+
+ );
+}
diff --git a/src/components/metrics/WeeklyTraffic.tsx b/src/components/metrics/WeeklyTraffic.tsx
new file mode 100644
index 0000000..90e47c6
--- /dev/null
+++ b/src/components/metrics/WeeklyTraffic.tsx
@@ -0,0 +1,112 @@
+import { Focusable, Grid, Row, Text, Tooltip, TooltipTrigger } from '@umami/react-zen';
+import { addHours, format, startOfDay } from 'date-fns';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useLocale, useMessages, useWeeklyTrafficQuery } from '@/components/hooks';
+import { getDayOfWeekAsDate } from '@/lib/date';
+
+export function WeeklyTraffic({ websiteId }: { websiteId: string }) {
+ const { data, isLoading, error } = useWeeklyTrafficQuery(websiteId);
+ const { dateLocale } = useLocale();
+ const { labels, formatMessage } = useMessages();
+ const { weekStartsOn } = dateLocale.options;
+ const daysOfWeek = Array(7)
+ .fill(weekStartsOn)
+ .map((d, i) => (d + i) % 7);
+
+ const [, max = 1] = data
+ ? data.reduce((arr: number[], hours: number[], index: number) => {
+ const min = Math.min(...hours);
+ const max = Math.max(...hours);
+
+ if (index === 0) {
+ return [min, max];
+ }
+
+ if (min < arr[0]) {
+ arr[0] = min;
+ }
+
+ if (max > arr[1]) {
+ arr[1] = max;
+ }
+
+ return arr;
+ }, [])
+ : [];
+
+ return (
+
+
+ {data && (
+ <>
+
+
+ {Array(24)
+ .fill(null)
+ .map((_, i) => {
+ const label = format(addHours(startOfDay(new Date()), i), 'haaa', {
+ locale: dateLocale,
+ });
+ return (
+
+
+ {label}
+
+
+ );
+ })}
+
+ {daysOfWeek.map((index: number) => {
+ const day = data[index];
+ return (
+
+
+
+ {format(getDayOfWeekAsDate(index), 'EEE', { locale: dateLocale })}
+
+
+ {day?.map((count: number, j) => {
+ const pct = max ? count / max : 0;
+ return (
+
+
+
+
+
+
+ {`${formatMessage(
+ labels.visitors,
+ )}: ${count}`}
+
+ );
+ })}
+
+ );
+ })}
+ >
+ )}
+
+
+ );
+}
diff --git a/src/components/metrics/WorldMap.tsx b/src/components/metrics/WorldMap.tsx
new file mode 100644
index 0000000..3c8fadb
--- /dev/null
+++ b/src/components/metrics/WorldMap.tsx
@@ -0,0 +1,105 @@
+import { Column, type ColumnProps, FloatingTooltip, useTheme } from '@umami/react-zen';
+import { colord } from 'colord';
+import { useMemo, useState } from 'react';
+import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps';
+import {
+ useCountryNames,
+ useLocale,
+ useMessages,
+ useWebsiteMetricsQuery,
+} from '@/components/hooks';
+import { getThemeColors } from '@/lib/colors';
+import { ISO_COUNTRIES, MAP_FILE } from '@/lib/constants';
+import { percentFilter } from '@/lib/filters';
+import { formatLongNumber } from '@/lib/format';
+
+export interface WorldMapProps extends ColumnProps {
+ websiteId?: string;
+ data?: any[];
+}
+
+export function WorldMap({ websiteId, data, ...props }: WorldMapProps) {
+ const [tooltip, setTooltipPopup] = useState();
+ const { theme } = useTheme();
+ const { colors } = getThemeColors(theme);
+ const { locale } = useLocale();
+ const { formatMessage, labels } = useMessages();
+ const { countryNames } = useCountryNames(locale);
+ const visitorsLabel = formatMessage(labels.visitors).toLocaleLowerCase(locale);
+ const unknownLabel = formatMessage(labels.unknown);
+
+ const { data: mapData } = useWebsiteMetricsQuery(websiteId, {
+ type: 'country',
+ });
+
+ const metrics = useMemo(
+ () => (data || mapData ? percentFilter((data || mapData) as any[]) : []),
+ [data, mapData],
+ );
+
+ const getFillColor = (code: string) => {
+ if (code === 'AQ') return;
+ const country = metrics?.find(({ x }) => x === code);
+
+ if (!country) {
+ return colors.map.fillColor;
+ }
+
+ return colord(colors.map.baseColor)
+ [theme === 'light' ? 'lighten' : 'darken'](0.4 * (1.0 - country.z / 100))
+ .toHex();
+ };
+
+ const getOpacity = (code: string) => {
+ return code === 'AQ' ? 0 : 1;
+ };
+
+ const handleHover = (code: string) => {
+ if (code === 'AQ') return;
+ const country = metrics?.find(({ x }) => x === code);
+ setTooltipPopup(
+ `${countryNames[code] || unknownLabel}: ${formatLongNumber(
+ country?.y || 0,
+ )} ${visitorsLabel}` as any,
+ );
+ };
+
+ return (
+
+
+
+
+ {({ geographies }) => {
+ return geographies.map(geo => {
+ const code = ISO_COUNTRIES[geo.id];
+
+ return (
+ handleHover(code)}
+ onMouseOut={() => setTooltipPopup(null)}
+ />
+ );
+ });
+ }}
+
+
+
+ {tooltip && {tooltip}}
+
+ );
+}
diff --git a/src/components/svg/AddUser.tsx b/src/components/svg/AddUser.tsx
new file mode 100644
index 0000000..d1eb509
--- /dev/null
+++ b/src/components/svg/AddUser.tsx
@@ -0,0 +1,16 @@
+import type { SVGProps } from 'react';
+
+const SvgAddUser = (props: SVGProps) => (
+
+);
+export default SvgAddUser;
diff --git a/src/components/svg/BarChart.tsx b/src/components/svg/BarChart.tsx
new file mode 100644
index 0000000..96ebe00
--- /dev/null
+++ b/src/components/svg/BarChart.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgBarChart = (props: SVGProps) => (
+
+);
+export default SvgBarChart;
diff --git a/src/components/svg/Bars.tsx b/src/components/svg/Bars.tsx
new file mode 100644
index 0000000..1ce88f7
--- /dev/null
+++ b/src/components/svg/Bars.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgBars = (props: SVGProps) => (
+
+);
+export default SvgBars;
diff --git a/src/components/svg/Bolt.tsx b/src/components/svg/Bolt.tsx
new file mode 100644
index 0000000..23b1e76
--- /dev/null
+++ b/src/components/svg/Bolt.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgBolt = (props: SVGProps) => (
+
+);
+export default SvgBolt;
diff --git a/src/components/svg/Bookmark.tsx b/src/components/svg/Bookmark.tsx
new file mode 100644
index 0000000..089f61f
--- /dev/null
+++ b/src/components/svg/Bookmark.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgBookmark = (props: SVGProps) => (
+
+);
+export default SvgBookmark;
diff --git a/src/components/svg/Calendar.tsx b/src/components/svg/Calendar.tsx
new file mode 100644
index 0000000..dfb848a
--- /dev/null
+++ b/src/components/svg/Calendar.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgCalendar = (props: SVGProps) => (
+
+);
+export default SvgCalendar;
diff --git a/src/components/svg/Change.tsx b/src/components/svg/Change.tsx
new file mode 100644
index 0000000..935a2f7
--- /dev/null
+++ b/src/components/svg/Change.tsx
@@ -0,0 +1,13 @@
+import type { SVGProps } from 'react';
+
+const SvgChange = (props: SVGProps) => (
+
+);
+export default SvgChange;
diff --git a/src/components/svg/Clock.tsx b/src/components/svg/Clock.tsx
new file mode 100644
index 0000000..2dfa6a6
--- /dev/null
+++ b/src/components/svg/Clock.tsx
@@ -0,0 +1,12 @@
+import type { SVGProps } from 'react';
+
+const SvgClock = (props: SVGProps) => (
+
+);
+export default SvgClock;
diff --git a/src/components/svg/Compare.tsx b/src/components/svg/Compare.tsx
new file mode 100644
index 0000000..3434461
--- /dev/null
+++ b/src/components/svg/Compare.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgCompare = (props: SVGProps) => (
+
+);
+export default SvgCompare;
diff --git a/src/components/svg/Dashboard.tsx b/src/components/svg/Dashboard.tsx
new file mode 100644
index 0000000..5696244
--- /dev/null
+++ b/src/components/svg/Dashboard.tsx
@@ -0,0 +1,21 @@
+import type { SVGProps } from 'react';
+
+const SvgDashboard = (props: SVGProps) => (
+
+);
+export default SvgDashboard;
diff --git a/src/components/svg/Download.tsx b/src/components/svg/Download.tsx
new file mode 100644
index 0000000..5f58724
--- /dev/null
+++ b/src/components/svg/Download.tsx
@@ -0,0 +1,9 @@
+import type { SVGProps } from 'react';
+
+const SvgDownload = (props: SVGProps) => (
+
+);
+export default SvgDownload;
diff --git a/src/components/svg/Expand.tsx b/src/components/svg/Expand.tsx
new file mode 100644
index 0000000..a0f472e
--- /dev/null
+++ b/src/components/svg/Expand.tsx
@@ -0,0 +1,18 @@
+import type { SVGProps } from 'react';
+
+const SvgExpand = (props: SVGProps) => (
+
+);
+export default SvgExpand;
diff --git a/src/components/svg/Export.tsx b/src/components/svg/Export.tsx
new file mode 100644
index 0000000..5c1ef14
--- /dev/null
+++ b/src/components/svg/Export.tsx
@@ -0,0 +1,12 @@
+import type { SVGProps } from 'react';
+
+const SvgExport = (props: SVGProps) => (
+
+);
+export default SvgExport;
diff --git a/src/components/svg/Flag.tsx b/src/components/svg/Flag.tsx
new file mode 100644
index 0000000..34af943
--- /dev/null
+++ b/src/components/svg/Flag.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgFlag = (props: SVGProps) => (
+
+);
+export default SvgFlag;
diff --git a/src/components/svg/Funnel.tsx b/src/components/svg/Funnel.tsx
new file mode 100644
index 0000000..63cf47d
--- /dev/null
+++ b/src/components/svg/Funnel.tsx
@@ -0,0 +1,18 @@
+import type { SVGProps } from 'react';
+
+const SvgFunnel = (props: SVGProps) => (
+
+);
+export default SvgFunnel;
diff --git a/src/components/svg/Gear.tsx b/src/components/svg/Gear.tsx
new file mode 100644
index 0000000..539b838
--- /dev/null
+++ b/src/components/svg/Gear.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgGear = (props: SVGProps) => (
+
+);
+export default SvgGear;
diff --git a/src/components/svg/Globe.tsx b/src/components/svg/Globe.tsx
new file mode 100644
index 0000000..385017d
--- /dev/null
+++ b/src/components/svg/Globe.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgGlobe = (props: SVGProps) => (
+
+);
+export default SvgGlobe;
diff --git a/src/components/svg/Lightbulb.tsx b/src/components/svg/Lightbulb.tsx
new file mode 100644
index 0000000..8d86170
--- /dev/null
+++ b/src/components/svg/Lightbulb.tsx
@@ -0,0 +1,15 @@
+import type { SVGProps } from 'react';
+
+const SvgLightbulb = (props: SVGProps) => (
+
+);
+export default SvgLightbulb;
diff --git a/src/components/svg/Lightning.tsx b/src/components/svg/Lightning.tsx
new file mode 100644
index 0000000..9539a96
--- /dev/null
+++ b/src/components/svg/Lightning.tsx
@@ -0,0 +1,33 @@
+import type { SVGProps } from 'react';
+
+const SvgLightning = (props: SVGProps) => (
+
+);
+export default SvgLightning;
diff --git a/src/components/svg/Link.tsx b/src/components/svg/Link.tsx
new file mode 100644
index 0000000..4ce88e7
--- /dev/null
+++ b/src/components/svg/Link.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgLink = (props: SVGProps) => (
+
+);
+export default SvgLink;
diff --git a/src/components/svg/Location.tsx b/src/components/svg/Location.tsx
new file mode 100644
index 0000000..0fd7d16
--- /dev/null
+++ b/src/components/svg/Location.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgLocation = (props: SVGProps) => (
+
+);
+export default SvgLocation;
diff --git a/src/components/svg/Lock.tsx b/src/components/svg/Lock.tsx
new file mode 100644
index 0000000..2b62eb9
--- /dev/null
+++ b/src/components/svg/Lock.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgLock = (props: SVGProps) => (
+
+);
+export default SvgLock;
diff --git a/src/components/svg/Logo.tsx b/src/components/svg/Logo.tsx
new file mode 100644
index 0000000..eb9fdf5
--- /dev/null
+++ b/src/components/svg/Logo.tsx
@@ -0,0 +1,17 @@
+import type { SVGProps } from 'react';
+
+const SvgLogo = (props: SVGProps) => (
+
+);
+export default SvgLogo;
diff --git a/src/components/svg/LogoWhite.tsx b/src/components/svg/LogoWhite.tsx
new file mode 100644
index 0000000..fb8c5f9
--- /dev/null
+++ b/src/components/svg/LogoWhite.tsx
@@ -0,0 +1,26 @@
+import type { SVGProps } from 'react';
+
+const SvgLogoWhite = (props: SVGProps) => (
+
+);
+export default SvgLogoWhite;
diff --git a/src/components/svg/Magnet.tsx b/src/components/svg/Magnet.tsx
new file mode 100644
index 0000000..88b0f03
--- /dev/null
+++ b/src/components/svg/Magnet.tsx
@@ -0,0 +1,15 @@
+import type { SVGProps } from 'react';
+
+const SvgMagnet = (props: SVGProps) => (
+
+);
+export default SvgMagnet;
diff --git a/src/components/svg/Money.tsx b/src/components/svg/Money.tsx
new file mode 100644
index 0000000..7d7b1e5
--- /dev/null
+++ b/src/components/svg/Money.tsx
@@ -0,0 +1,15 @@
+import type { SVGProps } from 'react';
+
+const SvgMoney = (props: SVGProps) => (
+
+);
+export default SvgMoney;
diff --git a/src/components/svg/Moon.tsx b/src/components/svg/Moon.tsx
new file mode 100644
index 0000000..40e3e8b
--- /dev/null
+++ b/src/components/svg/Moon.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgMoon = (props: SVGProps) => (
+
+);
+export default SvgMoon;
diff --git a/src/components/svg/Network.tsx b/src/components/svg/Network.tsx
new file mode 100644
index 0000000..15941a9
--- /dev/null
+++ b/src/components/svg/Network.tsx
@@ -0,0 +1,15 @@
+import type { SVGProps } from 'react';
+
+const SvgNetwork = (props: SVGProps) => (
+
+);
+export default SvgNetwork;
diff --git a/src/components/svg/Nodes.tsx b/src/components/svg/Nodes.tsx
new file mode 100644
index 0000000..1adfcb8
--- /dev/null
+++ b/src/components/svg/Nodes.tsx
@@ -0,0 +1,12 @@
+import type { SVGProps } from 'react';
+
+const SvgNodes = (props: SVGProps) => (
+
+);
+export default SvgNodes;
diff --git a/src/components/svg/Overview.tsx b/src/components/svg/Overview.tsx
new file mode 100644
index 0000000..67e6af1
--- /dev/null
+++ b/src/components/svg/Overview.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgOverview = (props: SVGProps) => (
+
+);
+export default SvgOverview;
diff --git a/src/components/svg/Path.tsx b/src/components/svg/Path.tsx
new file mode 100644
index 0000000..7538ba4
--- /dev/null
+++ b/src/components/svg/Path.tsx
@@ -0,0 +1,15 @@
+import type { SVGProps } from 'react';
+
+const SvgPath = (props: SVGProps) => (
+
+);
+export default SvgPath;
diff --git a/src/components/svg/Profile.tsx b/src/components/svg/Profile.tsx
new file mode 100644
index 0000000..c955fce
--- /dev/null
+++ b/src/components/svg/Profile.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgProfile = (props: SVGProps) => (
+
+);
+export default SvgProfile;
diff --git a/src/components/svg/Pushpin.tsx b/src/components/svg/Pushpin.tsx
new file mode 100644
index 0000000..d19e98e
--- /dev/null
+++ b/src/components/svg/Pushpin.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgPushpin = (props: SVGProps) => (
+
+);
+export default SvgPushpin;
diff --git a/src/components/svg/Redo.tsx b/src/components/svg/Redo.tsx
new file mode 100644
index 0000000..04c389f
--- /dev/null
+++ b/src/components/svg/Redo.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgRedo = (props: SVGProps) => (
+
+);
+export default SvgRedo;
diff --git a/src/components/svg/Reports.tsx b/src/components/svg/Reports.tsx
new file mode 100644
index 0000000..b548966
--- /dev/null
+++ b/src/components/svg/Reports.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgReports = (props: SVGProps) => (
+
+);
+export default SvgReports;
diff --git a/src/components/svg/Security.tsx b/src/components/svg/Security.tsx
new file mode 100644
index 0000000..d075a93
--- /dev/null
+++ b/src/components/svg/Security.tsx
@@ -0,0 +1,16 @@
+import type { SVGProps } from 'react';
+
+const SvgSecurity = (props: SVGProps) => (
+
+);
+export default SvgSecurity;
diff --git a/src/components/svg/Speaker.tsx b/src/components/svg/Speaker.tsx
new file mode 100644
index 0000000..eb724ae
--- /dev/null
+++ b/src/components/svg/Speaker.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgSpeaker = (props: SVGProps) => (
+
+);
+export default SvgSpeaker;
diff --git a/src/components/svg/Sun.tsx b/src/components/svg/Sun.tsx
new file mode 100644
index 0000000..61880f5
--- /dev/null
+++ b/src/components/svg/Sun.tsx
@@ -0,0 +1,9 @@
+import type { SVGProps } from 'react';
+
+const SvgSun = (props: SVGProps) => (
+
+);
+export default SvgSun;
diff --git a/src/components/svg/Switch.tsx b/src/components/svg/Switch.tsx
new file mode 100644
index 0000000..0196d85
--- /dev/null
+++ b/src/components/svg/Switch.tsx
@@ -0,0 +1,19 @@
+import type { SVGProps } from 'react';
+
+const SvgSwitch = (props: SVGProps) => (
+
+);
+export default SvgSwitch;
diff --git a/src/components/svg/Tag.tsx b/src/components/svg/Tag.tsx
new file mode 100644
index 0000000..2ff51f4
--- /dev/null
+++ b/src/components/svg/Tag.tsx
@@ -0,0 +1,16 @@
+import type { SVGProps } from 'react';
+
+const SvgTag = (props: SVGProps) => (
+
+);
+export default SvgTag;
diff --git a/src/components/svg/Target.tsx b/src/components/svg/Target.tsx
new file mode 100644
index 0000000..3fe76d2
--- /dev/null
+++ b/src/components/svg/Target.tsx
@@ -0,0 +1,21 @@
+import type { SVGProps } from 'react';
+
+const SvgTarget = (props: SVGProps) => (
+
+);
+export default SvgTarget;
diff --git a/src/components/svg/Visitor.tsx b/src/components/svg/Visitor.tsx
new file mode 100644
index 0000000..16db585
--- /dev/null
+++ b/src/components/svg/Visitor.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgVisitor = (props: SVGProps) => (
+
+);
+export default SvgVisitor;
diff --git a/src/components/svg/Website.tsx b/src/components/svg/Website.tsx
new file mode 100644
index 0000000..20a18a4
--- /dev/null
+++ b/src/components/svg/Website.tsx
@@ -0,0 +1,13 @@
+import type { SVGProps } from 'react';
+
+const SvgWebsite = (props: SVGProps) => (
+
+);
+export default SvgWebsite;
diff --git a/src/components/svg/index.ts b/src/components/svg/index.ts
new file mode 100644
index 0000000..76756af
--- /dev/null
+++ b/src/components/svg/index.ts
@@ -0,0 +1,37 @@
+export { default as AddUser } from './AddUser';
+export { default as BarChart } from './BarChart';
+export { default as Bars } from './Bars';
+export { default as Bolt } from './Bolt';
+export { default as Bookmark } from './Bookmark';
+export { default as Change } from './Change';
+export { default as Compare } from './Compare';
+export { default as Dashboard } from './Dashboard';
+export { default as Download } from './Download';
+export { default as Expand } from './Expand';
+export { default as Export } from './Export';
+export { default as Flag } from './Flag';
+export { default as Funnel } from './Funnel';
+export { default as Gear } from './Gear';
+export { default as Lightbulb } from './Lightbulb';
+export { default as Lightning } from './Lightning';
+export { default as Location } from './Location';
+export { default as Lock } from './Lock';
+export { default as Logo } from './Logo';
+export { default as LogoWhite } from './LogoWhite';
+export { default as Magnet } from './Magnet';
+export { default as Money } from './Money';
+export { default as Network } from './Network';
+export { default as Nodes } from './Nodes';
+export { default as Overview } from './Overview';
+export { default as Path } from './Path';
+export { default as Profile } from './Profile';
+export { default as Pushpin } from './Pushpin';
+export { default as Redo } from './Redo';
+export { default as Reports } from './Reports';
+export { default as Security } from './Security';
+export { default as Speaker } from './Speaker';
+export { default as Switch } from './Switch';
+export { default as Tag } from './Tag';
+export { default as Target } from './Target';
+export { default as Visitor } from './Visitor';
+export { default as Website } from './Website';
--
cgit v1.2.3