Commit 7b9e9017 authored by Igor Stuev's avatar Igor Stuev Committed by GitHub

Merge pull request #2022 from blockscout/fe-1966

Stats section updates
parents 134b6a18 54807c91
......@@ -14,7 +14,7 @@ NEXT_PUBLIC_AD_ADBUTLER_CONFIG_DESKTOP={ "id": "632019", "width": "728", "height
NEXT_PUBLIC_AD_ADBUTLER_CONFIG_MOBILE={ "id": "632018", "width": "320", "height": "100" }
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
NEXT_PUBLIC_API_BASE_PATH=/
NEXT_PUBLIC_API_HOST=eth-sepolia.blockscout.com
NEXT_PUBLIC_API_HOST=eth-sepolia.k8s-dev.blockscout.com
NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml
NEXT_PUBLIC_CONTRACT_CODE_IDES=[{'title':'Remix IDE','url':'https://remix.ethereum.org/?address={hash}&blockscout={domain}','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}]
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com
......@@ -59,7 +59,7 @@ NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-c
NEXT_PUBLIC_OTHER_LINKS=[{'url':'https://sepolia.drpc.org?ref=559183','text':'Public RPC'}]
NEXT_PUBLIC_SAFE_TX_SERVICE_URL=https://safe-transaction-sepolia.safe.global
NEXT_PUBLIC_SENTRY_ENABLE_TRACING=true
NEXT_PUBLIC_STATS_API_HOST=https://stats-sepolia.k8s.blockscout.com
NEXT_PUBLIC_STATS_API_HOST=https://stats-sepolia.k8s-dev.blockscout.com
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=noves
NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
\ No newline at end of file
......@@ -248,7 +248,7 @@ export default function useNavItems(): ReturnType {
text: 'Charts & stats',
nextRoute: { pathname: '/stats' as const },
icon: 'stats',
isActive: pathname === '/stats',
isActive: pathname.startsWith('/stats'),
} : null,
apiNavItems.length > 0 && {
text: 'API',
......
......@@ -20,7 +20,7 @@ export default function generate<Pathname extends Route['pathname']>(route: Rout
};
const title = compileValue(templates.title.make(route.pathname, Boolean(apiData)), params);
const description = compileValue(templates.description.make(route.pathname), params);
const description = compileValue(templates.description.make(route.pathname, Boolean(apiData)), params);
const pageOgType = getPageOgType(route.pathname);
......
......@@ -23,6 +23,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/apps': 'Root page',
'/apps/[id]': 'Regular page',
'/stats': 'Root page',
'/stats/[id]': 'Regular page',
'/api-docs': 'Regular page',
'/graphiql': 'Regular page',
'/search-results': 'Regular page',
......
......@@ -27,6 +27,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/apps': DEFAULT_TEMPLATE,
'/apps/[id]': DEFAULT_TEMPLATE,
'/stats': DEFAULT_TEMPLATE,
'/stats/[id]': DEFAULT_TEMPLATE,
'/api-docs': DEFAULT_TEMPLATE,
'/graphiql': DEFAULT_TEMPLATE,
'/search-results': DEFAULT_TEMPLATE,
......@@ -71,8 +72,10 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/auth/unverified-email': DEFAULT_TEMPLATE,
};
export function make(pathname: Route['pathname']) {
const template = TEMPLATE_MAP[pathname];
const TEMPLATE_MAP_ENHANCED: Partial<Record<Route['pathname'], string>> = {
'/stats/[id]': '%description%',
};
return template ?? '';
export function make(pathname: Route['pathname'], isEnriched = false) {
return (isEnriched ? TEMPLATE_MAP_ENHANCED[pathname] : undefined) ?? TEMPLATE_MAP[pathname] ?? '';
}
......@@ -23,6 +23,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/apps': '%network_name% DApps - Explore top apps',
'/apps/[id]': '%network_name% marketplace app',
'/stats': '%network_name% stats - %network_name% network insights',
'/stats/[id]': '%network_name% stats - %id% chart',
'/api-docs': '%network_name% API docs - %network_name% developer tools',
'/graphiql': 'GraphQL for %network_name% - %network_name% data query',
'/search-results': '%network_name% search result for %q%',
......@@ -72,6 +73,7 @@ const TEMPLATE_MAP_ENHANCED: Partial<Record<Route['pathname'], string>> = {
'/token/[hash]/instance/[id]': '%network_name% token instance for %symbol%',
'/apps/[id]': '%network_name% - %app_name%',
'/address/[hash]': '%network_name% address details for %domain_name%',
'/stats/[id]': '%title% chart on %network_name%',
};
export function make(pathname: Route['pathname'], isEnriched = false) {
......
import type { LineChart } from '@blockscout/stats-types';
import type { TokenInfo } from 'types/api/token';
import type { Route } from 'nextjs-routes';
......@@ -9,6 +10,7 @@ export type ApiData<Pathname extends Route['pathname']> =
Pathname extends '/token/[hash]' ? TokenInfo :
Pathname extends '/token/[hash]/instance/[id]' ? { symbol: string } :
Pathname extends '/apps/[id]' ? { app_name: string } :
Pathname extends '/stats/[id]' ? LineChart['info'] :
never
) | null;
......
......@@ -21,6 +21,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/apps': 'DApps',
'/apps/[id]': 'DApp',
'/stats': 'Stats',
'/stats/[id]': 'Stats chart',
'/api-docs': 'REST API',
'/graphiql': 'GraphQL',
'/search-results': 'Search results',
......
......@@ -116,6 +116,9 @@ Type extends EventTypes.PAGE_WIDGET ? (
'Type': 'Address tag';
'Info': string;
'URL': string;
} | {
'Type': 'Share chart';
'Info': string;
}
) :
Type extends EventTypes.TX_INTERPRETATION_INTERACTION ? {
......
......@@ -4,158 +4,195 @@ export const averageGasPrice: stats.LineChart = {
chart: [
{
date: '2023-12-22',
date_to: '2023-12-22',
value: '37.7804422597599',
is_approximate: false,
},
{
date: '2023-12-23',
date_to: '2023-12-23',
value: '25.84889883009387',
is_approximate: false,
},
{
date: '2023-12-24',
date_to: '2023-12-24',
value: '25.818463227198574',
is_approximate: false,
},
{
date: '2023-12-25',
date_to: '2023-12-25',
value: '26.045513050051298',
is_approximate: false,
},
{
date: '2023-12-26',
date_to: '2023-12-26',
value: '21.42600692652399',
is_approximate: false,
},
{
date: '2023-12-27',
date_to: '2023-12-27',
value: '31.066730409846656',
is_approximate: false,
},
{
date: '2023-12-28',
date_to: '2023-12-28',
value: '33.63955781902089',
is_approximate: false,
},
{
date: '2023-12-29',
date_to: '2023-12-29',
value: '28.064736756058384',
is_approximate: false,
},
{
date: '2023-12-30',
date_to: '2023-12-30',
value: '23.074500869678175',
is_approximate: false,
},
{
date: '2023-12-31',
date_to: '2023-12-31',
value: '17.651005734615133',
is_approximate: false,
},
{
date: '2024-01-01',
date_to: '2023-01-01',
value: '14.906085174476441',
is_approximate: false,
},
{
date: '2024-01-02',
date_to: '2023-01-02',
value: '22.28459059038656',
is_approximate: false,
},
{
date: '2024-01-03',
date_to: '2023-01-03',
value: '39.8311646806592',
is_approximate: false,
},
{
date: '2024-01-04',
date_to: '2023-01-04',
value: '26.09989322256083',
is_approximate: false,
},
{
date: '2024-01-05',
date_to: '2023-01-05',
value: '22.821996688111998',
is_approximate: false,
},
{
date: '2024-01-06',
date_to: '2023-01-06',
value: '20.32680041262083',
is_approximate: false,
},
{
date: '2024-01-07',
date_to: '2023-01-07',
value: '32.535045831809704',
is_approximate: false,
},
{
date: '2024-01-08',
date_to: '2023-01-08',
value: '27.443477102139482',
is_approximate: false,
},
{
date: '2024-01-09',
date_to: '2023-01-09',
value: '20.7911332558055',
is_approximate: false,
},
{
date: '2024-01-10',
date_to: '2023-01-10',
value: '42.10740192523919',
is_approximate: false,
},
{
date: '2024-01-11',
date_to: '2023-01-11',
value: '35.75215680343582',
is_approximate: false,
},
{
date: '2024-01-12',
date_to: '2023-01-12',
value: '27.430414798093253',
is_approximate: false,
},
{
date: '2024-01-13',
date_to: '2023-01-13',
value: '20.170934096589875',
is_approximate: false,
},
{
date: '2024-01-14',
date_to: '2023-01-14',
value: '38.79660984371034',
is_approximate: false,
},
{
date: '2024-01-15',
date_to: '2023-01-15',
value: '26.140740484554204',
is_approximate: false,
},
{
date: '2024-01-16',
date_to: '2023-01-16',
value: '36.708543184194156',
is_approximate: false,
},
{
date: '2024-01-17',
date_to: '2023-01-17',
value: '40.325438794298876',
is_approximate: false,
},
{
date: '2024-01-18',
date_to: '2023-01-18',
value: '37.55145309930694',
is_approximate: false,
},
{
date: '2024-01-19',
date_to: '2023-01-19',
value: '33.271450114434664',
is_approximate: false,
},
{
date: '2024-01-20',
date_to: '2023-01-20',
value: '19.303304377685638',
is_approximate: false,
},
{
date: '2024-01-21',
date_to: '2023-01-21',
value: '14.375908594704976',
is_approximate: false,
},
],
info: {
title: 'Chart title',
description: 'Chart description',
id: 'chart',
resolutions: [ 'DAY', 'MONTH' ],
},
};
......@@ -11,18 +11,21 @@ export const base: stats.LineCharts = {
title: 'Accounts growth',
description: 'Cumulative accounts number per period',
units: undefined,
resolutions: [ 'DAY', 'MONTH' ],
},
{
id: 'activeAccounts',
title: 'Active accounts',
description: 'Active accounts number per period',
units: undefined,
resolutions: [ 'DAY', 'MONTH' ],
},
{
id: 'newAccounts',
title: 'New accounts',
description: 'New accounts number per day',
units: undefined,
resolutions: [ 'DAY', 'MONTH' ],
},
],
},
......@@ -35,30 +38,35 @@ export const base: stats.LineCharts = {
title: 'Average transaction fee',
description: 'The average amount in ETH spent per transaction',
units: 'ETH',
resolutions: [ 'DAY', 'MONTH' ],
},
{
id: 'newTxns',
title: 'New transactions',
description: 'New transactions number',
units: undefined,
resolutions: [ 'DAY', 'MONTH' ],
},
{
id: 'txnsFee',
title: 'Transactions fees',
description: 'Amount of tokens paid as fees',
units: 'ETH',
resolutions: [ 'DAY', 'MONTH' ],
},
{
id: 'txnsGrowth',
title: 'Transactions growth',
description: 'Cumulative transactions number',
units: undefined,
resolutions: [ 'DAY', 'MONTH' ],
},
{
id: 'txnsSuccessRate',
title: 'Transactions success rate',
description: 'Successful transactions rate per day',
units: undefined,
resolutions: [ 'DAY', 'MONTH' ],
},
],
},
......@@ -71,18 +79,21 @@ export const base: stats.LineCharts = {
title: 'Average block rewards',
description: 'Average amount of distributed reward in tokens per day',
units: 'ETH',
resolutions: [ 'DAY', 'MONTH' ],
},
{
id: 'averageBlockSize',
title: 'Average block size',
description: 'Average size of blocks in bytes',
units: 'Bytes',
resolutions: [ 'DAY', 'MONTH' ],
},
{
id: 'newBlocks',
title: 'New blocks',
description: 'New blocks number',
units: undefined,
resolutions: [ 'DAY', 'MONTH' ],
},
],
},
......@@ -95,6 +106,7 @@ export const base: stats.LineCharts = {
title: 'New ETH transfers',
description: 'New token transfers number for the period',
units: undefined,
resolutions: [ 'DAY', 'MONTH' ],
},
],
},
......@@ -107,18 +119,21 @@ export const base: stats.LineCharts = {
title: 'Average gas limit',
description: 'Average gas limit per block for the period',
units: undefined,
resolutions: [ 'DAY', 'MONTH' ],
},
{
id: 'averageGasPrice',
title: 'Average gas price',
description: 'Average gas price for the period (Gwei)',
units: 'Gwei',
resolutions: [ 'DAY', 'MONTH' ],
},
{
id: 'gasUsedGrowth',
title: 'Gas used growth',
description: 'Cumulative gas used for the period',
units: undefined,
resolutions: [ 'DAY', 'MONTH' ],
},
],
},
......@@ -131,12 +146,14 @@ export const base: stats.LineCharts = {
title: 'New verified contracts',
description: 'New verified contracts number for the period',
units: undefined,
resolutions: [ 'DAY', 'MONTH' ],
},
{
id: 'verifiedContractsGrowth',
title: 'Verified contracts growth',
description: 'Cumulative number verified contracts for the period',
units: undefined,
resolutions: [ 'DAY', 'MONTH' ],
},
],
},
......
......@@ -55,6 +55,7 @@ declare module "nextjs-routes" {
| StaticRoute<"/public-tags/submit">
| StaticRoute<"/search-results">
| StaticRoute<"/sprite">
| DynamicRoute<"/stats/[id]", { "id": string }>
| StaticRoute<"/stats">
| DynamicRoute<"/token/[hash]", { "hash": string }>
| DynamicRoute<"/token/[hash]/instance/[id]", { "hash": string; "id": string }>
......
......@@ -12,6 +12,7 @@ type Params<R extends ResourceName> = (
{
resource: R;
pathParams?: ResourcePathParams<R>;
queryParams?: Record<string, string | number | undefined>;
} | {
url: string;
route: string;
......@@ -22,12 +23,11 @@ type Params<R extends ResourceName> = (
export default async function fetchApi<R extends ResourceName = never, S = ResourcePayload<R>>(params: Params<R>): Promise<S | undefined> {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, params.timeout || SECOND);
const url = 'url' in params ? params.url : buildUrl(params.resource, params.pathParams);
const url = 'url' in params ? params.url : buildUrl(params.resource, params.pathParams, params.queryParams);
const route = 'route' in params ? params.route : RESOURCES[params.resource]['path'];
const end = metrics?.apiRequestDuration.startTimer();
......
import type { GetServerSideProps, NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import type { Route } from 'nextjs-routes';
import * as gSSP from 'nextjs/getServerSideProps';
import type { Props } from 'nextjs/getServerSideProps';
import PageNextJs from 'nextjs/PageNextJs';
import detectBotRequest from 'nextjs/utils/detectBotRequest';
import fetchApi from 'nextjs/utils/fetchApi';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import getQueryParamString from 'lib/router/getQueryParamString';
const Chart = dynamic(() => import('ui/pages/Chart'), { ssr: false });
const pathname: Route['pathname'] = '/stats/[id]';
const Page: NextPage<Props<typeof pathname>> = (props: Props<typeof pathname>) => {
return (
<PageNextJs pathname="/stats/[id]" query={ props.query } apiData={ props.apiData }>
<Chart/>
</PageNextJs>
);
};
export default Page;
export const getServerSideProps: GetServerSideProps<Props<typeof pathname>> = async(ctx) => {
const baseResponse = await gSSP.base<typeof pathname>(ctx);
if ('props' in baseResponse) {
if (
config.meta.seo.enhancedDataEnabled ||
(config.meta.og.enhancedDataEnabled && detectBotRequest(ctx.req)?.type === 'social_preview')
) {
const chartData = await fetchApi({
resource: 'stats_line',
pathParams: { id: getQueryParamString(ctx.query.id) },
queryParams: { from: dayjs().format('YYYY-MM-DD'), to: dayjs().format('YYYY-MM-DD') },
timeout: 1000,
});
(await baseResponse.props).apiData = chartData?.info ?? null;
}
}
return baseResponse;
};
......@@ -51,24 +51,28 @@ export const STATS_CHARTS_SECTION: stats.LineChartSection = {
title: 'Average transaction fee',
description: 'The average amount in ETH spent per transaction',
units: 'ETH',
resolutions: [ 'DAY', 'MONTH' ],
},
{
id: 'chart_1',
title: 'Transactions fees',
description: 'Amount of tokens paid as fees',
units: 'ETH',
resolutions: [ 'DAY', 'MONTH' ],
},
{
id: 'chart_2',
title: 'New transactions',
description: 'New transactions number',
units: undefined,
resolutions: [ 'DAY', 'MONTH' ],
},
{
id: 'chart_3',
title: 'Transactions growth',
description: 'Cumulative transactions number',
units: undefined,
resolutions: [ 'DAY', 'MONTH' ],
},
],
};
......
......@@ -47,6 +47,16 @@ const sizes = {
lineHeight: 5,
},
}),
md: definePartsStyle({
container: {
minH: 8,
minW: 8,
fontSize: 'sm',
px: '6px',
py: '6px',
lineHeight: 5,
},
}),
};
const baseStyleContainer = defineStyle({
......
import React from 'react';
import * as balanceHistoryMock from 'mocks/address/coinBalanceHistory';
import { test, expect } from 'playwright/lib';
import { test, expect, devices } from 'playwright/lib';
import AddressCoinBalance from './AddressCoinBalance';
......@@ -12,7 +12,7 @@ const hooksConfig = {
},
};
test('base view +@dark-mode +@mobile', async({ render, page, mockApiResponse }) => {
test('base view +@dark-mode', async({ render, page, mockApiResponse }) => {
await mockApiResponse('address_coin_balance', balanceHistoryMock.baseResponse, { pathParams: { hash: addressHash } });
await mockApiResponse('address_coin_balance_chart', balanceHistoryMock.chartResponse, { pathParams: { hash: addressHash } });
const component = await render(<AddressCoinBalance/>, { hooksConfig });
......@@ -23,3 +23,19 @@ test('base view +@dark-mode +@mobile', async({ render, page, mockApiResponse })
await page.mouse.move(240, 100);
await expect(component).toHaveScreenshot();
});
test.describe('mobile', () => {
test.use({ viewport: devices['iPhone 13 Pro'].viewport });
test('base view', async({ render, page, mockApiResponse }) => {
await mockApiResponse('address_coin_balance', balanceHistoryMock.baseResponse, { pathParams: { hash: addressHash } });
await mockApiResponse('address_coin_balance_chart', balanceHistoryMock.chartResponse, { pathParams: { hash: addressHash } });
const component = await render(<AddressCoinBalance/>, { hooksConfig });
await page.waitForFunction(() => {
return document.querySelector('path[data-name="chart-Balances-small"]')?.getAttribute('opacity') === '1';
});
await page.mouse.move(100, 100);
await page.mouse.move(240, 100);
await expect(component).toHaveScreenshot();
});
});
import React from 'react';
import * as statsLineMock from 'mocks/stats/line';
import { test, expect } from 'playwright/lib';
import formatDate from 'ui/shared/chart/utils/formatIntervalDate';
import Chart from './Chart';
const CHART_ID = 'averageGasPrice';
test.beforeEach(async({ mockTextAd }) => {
await mockTextAd();
});
const hooksConfig = {
router: {
query: { id: CHART_ID },
},
};
test('base view +@dark-mode +@mobile', async({ render, mockApiResponse, page }) => {
const date = new Date();
date.setMonth(date.getMonth() - 1);
const chartApiUrl = await mockApiResponse(
'stats_line',
statsLineMock.averageGasPrice,
{
pathParams: { id: CHART_ID },
queryParams: {
from: formatDate(date),
to: '2022-11-11',
resolution: 'DAY',
},
},
);
const component = await render(<Chart/>, { hooksConfig });
await page.waitForResponse(chartApiUrl);
await page.waitForFunction(() => {
return document.querySelector('path[data-name="chart-Charttitle-fullscreen"]')?.getAttribute('opacity') === '1';
});
await expect(component).toHaveScreenshot();
});
import { Button, Flex, Link, Text } from '@chakra-ui/react';
import type { NextRouter } from 'next/router';
import { useRouter } from 'next/router';
import React from 'react';
import { Resolution } from '@blockscout/stats-types';
import type { StatsIntervalIds } from 'types/client/stats';
import { StatsIntervalId } from 'types/client/stats';
import config from 'configs/app';
import { useAppContext } from 'lib/contexts/app';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import useIsMobile from 'lib/hooks/useIsMobile';
import isBrowser from 'lib/isBrowser';
import * as metadata from 'lib/metadata';
import * as mixpanel from 'lib/mixpanel/index';
import getQueryParamString from 'lib/router/getQueryParamString';
import isCustomAppError from 'ui/shared/AppError/isCustomAppError';
import ChartIntervalSelect from 'ui/shared/chart/ChartIntervalSelect';
import ChartMenu from 'ui/shared/chart/ChartMenu';
import ChartResolutionSelect from 'ui/shared/chart/ChartResolutionSelect';
import ChartWidgetContent from 'ui/shared/chart/ChartWidgetContent';
import useChartQuery from 'ui/shared/chart/useChartQuery';
import useZoom from 'ui/shared/chart/useZoom';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import IconSvg from 'ui/shared/IconSvg';
import PageTitle from 'ui/shared/Page/PageTitle';
const DEFAULT_RESOLUTION = Resolution.DAY;
const getIntervalByResolution = (resolution: Resolution): StatsIntervalIds => {
switch (resolution) {
case 'DAY':
return 'oneMonth';
case 'WEEK':
return 'oneMonth';
case 'MONTH':
return 'oneYear';
case 'YEAR':
return 'all';
default:
return 'oneMonth';
}
};
const getIntervalFromQuery = (router: NextRouter): StatsIntervalIds | undefined => {
const intervalFromQuery = getQueryParamString(router.query.interval);
if (!intervalFromQuery || !Object.values(StatsIntervalId).includes(intervalFromQuery as StatsIntervalIds)) {
return undefined;
}
return intervalFromQuery as StatsIntervalIds;
};
const getResolutionFromQuery = (router: NextRouter) => {
const resolutionFromQuery = getQueryParamString(router.query.resolution);
if (!resolutionFromQuery || !Resolution[resolutionFromQuery as keyof typeof Resolution]) {
return DEFAULT_RESOLUTION;
}
return resolutionFromQuery as Resolution;
};
const Chart = () => {
const router = useRouter();
const id = getQueryParamString(router.query.id);
const intervalFromQuery = getIntervalFromQuery(router);
const resolutionFromQuery = getResolutionFromQuery(router);
const [ intervalState, setIntervalState ] = React.useState<StatsIntervalIds | undefined>(intervalFromQuery);
const [ resolution, setResolution ] = React.useState<Resolution>(resolutionFromQuery || DEFAULT_RESOLUTION);
const { zoomRange, handleZoom, handleZoomReset } = useZoom();
const interval = intervalState || getIntervalByResolution(resolution);
const ref = React.useRef(null);
const isMobile = useIsMobile();
const isInBrowser = isBrowser();
const appProps = useAppContext();
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/stats');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to charts list',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
const onIntervalChange = React.useCallback((interval: StatsIntervalIds) => {
setIntervalState(interval);
router.push(
{
pathname: router.pathname,
query: { ...router.query, interval },
},
undefined,
{ shallow: true },
);
}, [ setIntervalState, router ]);
const onResolutionChange = React.useCallback((resolution: Resolution) => {
setResolution(resolution);
router.push({
pathname: router.pathname,
query: { ...router.query, resolution },
});
}, [ setResolution, router ]);
const handleReset = React.useCallback(() => {
handleZoomReset();
onResolutionChange(DEFAULT_RESOLUTION);
}, [ handleZoomReset, onResolutionChange ]);
const { items, info, lineQuery } = useChartQuery(id, resolution, interval);
React.useEffect(() => {
if (info && !config.meta.seo.enhancedDataEnabled) {
metadata.update({ pathname: '/stats/[id]', query: { id } }, info);
}
}, [ info, id ]);
const onShare = React.useCallback(async() => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Share chart', Info: id });
try {
await window.navigator.share({
title: info?.title,
text: info?.description,
url: window.location.href,
});
} catch (error) {}
}, [ info, id ]);
if (lineQuery.isError) {
if (isCustomAppError(lineQuery.error)) {
throwOnResourceLoadError({ resource: 'stats_line', error: lineQuery.error, isError: true });
}
}
const hasItems = (items && items.length > 2) || lineQuery.isPending;
const isInfoLoading = !info && lineQuery.isPlaceholderData;
const shareButton = (
<Button
leftIcon={ <IconSvg name="share" w={ 4 } h={ 4 }/> }
colorScheme="blue"
size="sm"
variant="outline"
onClick={ onShare }
ml={ 6 }
>
Share
</Button>
);
return (
<>
<PageTitle
title={ info?.title || lineQuery.data?.info?.title || '' }
mb={ 3 }
isLoading={ isInfoLoading }
backLink={ backLink }
secondRow={ info?.description || lineQuery.data?.info?.description }
withTextAd
/>
<Flex alignItems="center" justifyContent="space-between">
<Flex alignItems="center" gap={{ base: 3, lg: 6 }} maxW="100%" overflow="hidden">
<Flex alignItems="center" gap={ 3 }>
{ !isMobile && <Text>Period</Text> }
<ChartIntervalSelect interval={ interval } onIntervalChange={ onIntervalChange }/>
</Flex>
{ lineQuery.data?.info?.resolutions && lineQuery.data?.info?.resolutions.length > 1 && (
<Flex alignItems="center" gap={ 3 }>
<Text>{ isMobile ? 'Res.' : 'Resolution' }</Text>
<ChartResolutionSelect
resolution={ resolution }
onResolutionChange={ onResolutionChange }
resolutions={ lineQuery.data?.info?.resolutions || [] }
/>
</Flex>
) }
{ (Boolean(zoomRange)) && (
<Link
onClick={ handleReset }
display="flex"
alignItems="center"
gap={ 2 }
>
<IconSvg name="repeat" w={ 5 } h={ 5 }/>
{ !isMobile && 'Reset' }
</Link>
) }
</Flex>
<Flex alignItems="center" gap={ 3 }>
{ /* TS thinks window.navigator.share can't be undefined, but it can */ }
{ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ }
{ !isMobile && (isInBrowser && ((window.navigator.share as any) ?
shareButton :
(
<CopyToClipboard
text={ config.app.baseUrl + router.asPath }
size={ 5 }
type="link"
variant="outline"
colorScheme="blue"
display="flex"
borderRadius="8px"
width={ 8 }
height={ 8 }
/>
)
)) }
{ (hasItems || lineQuery.isPlaceholderData) && (
<ChartMenu
items={ items }
title={ info?.title || '' }
isLoading={ lineQuery.isPlaceholderData }
chartRef={ ref }
resolution={ resolution }
zoomRange={ zoomRange }
handleZoom={ handleZoom }
handleZoomReset={ handleZoomReset }
chartUrl={ isMobile ? window.location.href : undefined }
/>
) }
</Flex>
</Flex>
<Flex
ref={ ref }
flexGrow={ 1 }
h="50vh"
mt={ 3 }
position="relative"
>
<ChartWidgetContent
isError={ lineQuery.isError }
items={ items }
title={ info?.title || '' }
units={ info?.units || undefined }
isEnlarged
isLoading={ lineQuery.isPlaceholderData }
zoomRange={ zoomRange }
handleZoom={ handleZoom }
emptyText="No data for the selected resolution & interval."
resolution={ resolution }
/>
</Flex>
</>
);
};
export default Chart;
......@@ -22,6 +22,7 @@ const CopyToClipboard = ({ text, className, isLoading, onClick, size = 5, type,
// have to implement controlled tooltip because of the issue - https://github.com/chakra-ui/chakra-ui/issues/7107
const { isOpen, onOpen, onClose } = useDisclosure();
const iconColor = useColorModeValue('gray.400', 'gray.500');
const colorProps = colorScheme ? {} : { color: iconColor };
const iconName = icon || (type === 'link' ? 'link' : 'copy');
useEffect(() => {
......@@ -44,10 +45,10 @@ const CopyToClipboard = ({ text, className, isLoading, onClick, size = 5, type,
return (
<Tooltip label={ copied ? 'Copied' : `Copy${ type === 'link' ? ' link ' : ' ' }to clipboard` } isOpen={ isOpen || copied }>
<IconButton
{ ...colorProps }
aria-label="copy"
icon={ <IconSvg name={ iconName } boxSize={ size }/> }
boxSize={ size }
color={ iconColor }
variant={ variant }
colorScheme={ colorScheme }
display="inline-block"
......
......@@ -154,9 +154,9 @@ const PageTitle = ({ title, contentAfter, withTextAd, backLink, className, isLoa
{ withTextAd && <TextAd order={{ base: -1, lg: 100 }} mb={{ base: 6, lg: 0 }} ml="auto" w={{ base: '100%', lg: 'auto' }}/> }
</Flex>
{ secondRow && (
<Flex alignItems="center" minH={ 10 } overflow="hidden" _empty={{ display: 'none' }}>
<Skeleton isLoaded={ !isLoading } alignItems="center" minH={ 10 } overflow="hidden" display="flex" _empty={{ display: 'none' }}>
{ secondRow }
</Flex>
</Skeleton>
) }
</Flex>
);
......
import type { TagProps } from '@chakra-ui/react';
import { Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { StatsInterval, StatsIntervalIds } from 'types/client/stats';
import TagGroupSelect from 'ui/shared/tagGroupSelect/TagGroupSelect';
import { STATS_INTERVALS } from 'ui/stats/constants';
import StatsDropdownMenu from 'ui/stats/StatsDropdownMenu';
const intervalList = Object.keys(STATS_INTERVALS).map((id: string) => ({
id: id,
title: STATS_INTERVALS[id as StatsIntervalIds].title,
})) as Array<StatsInterval>;
const intervalListShort = Object.keys(STATS_INTERVALS).map((id: string) => ({
id: id,
title: STATS_INTERVALS[id as StatsIntervalIds].shortTitle,
})) as Array<StatsInterval>;
type Props = {
interval: StatsIntervalIds;
onIntervalChange: (newInterval: StatsIntervalIds) => void;
isLoading?: boolean;
selectTagSize?: TagProps['size'];
}
const ChartIntervalSelect = ({ interval, onIntervalChange, isLoading, selectTagSize }: Props) => {
return (
<>
<Skeleton display={{ base: 'none', lg: 'flex' }} borderRadius="base" isLoaded={ !isLoading }>
<TagGroupSelect<StatsIntervalIds> items={ intervalListShort } onChange={ onIntervalChange } value={ interval } tagSize={ selectTagSize }/>
</Skeleton>
<Skeleton display={{ base: 'block', lg: 'none' }} borderRadius="base" isLoaded={ !isLoading }>
<StatsDropdownMenu
items={ intervalList }
selectedId={ interval }
onSelect={ onIntervalChange }
/>
</Skeleton>
</>
);
};
export default React.memo(ChartIntervalSelect);
import {
IconButton,
MenuButton,
MenuItem,
MenuList,
Skeleton,
useClipboard,
useColorModeValue,
VisuallyHidden,
} from '@chakra-ui/react';
import domToImage from 'dom-to-image';
import React from 'react';
import type { TimeChartItem } from './types';
import type { Resolution } from '@blockscout/stats-types';
import dayjs from 'lib/date/dayjs';
import isBrowser from 'lib/isBrowser';
import saveAsCSV from 'lib/saveAsCSV';
import Menu from 'ui/shared/chakra/Menu';
import IconSvg from 'ui/shared/IconSvg';
import FullscreenChartModal from './FullscreenChartModal';
export type Props = {
items?: Array<TimeChartItem>;
title: string;
description?: string;
units?: string;
isLoading: boolean;
chartRef: React.RefObject<HTMLDivElement>;
chartUrl?: string;
resolution?: Resolution;
zoomRange?: [ Date, Date ];
handleZoom: (range: [ Date, Date ]) => void;
handleZoomReset: () => void;
}
const DOWNLOAD_IMAGE_SCALE = 5;
const ChartMenu = ({
items,
title,
description,
units,
isLoading,
chartRef,
chartUrl,
resolution,
zoomRange,
handleZoom,
handleZoomReset,
}: Props) => {
const pngBackgroundColor = useColorModeValue('white', 'black');
const [ isFullscreen, setIsFullscreen ] = React.useState(false);
const { onCopy } = useClipboard(chartUrl ?? '');
const isInBrowser = isBrowser();
const showChartFullscreen = React.useCallback(() => {
setIsFullscreen(true);
}, []);
const clearFullscreenChart = React.useCallback(() => {
setIsFullscreen(false);
}, []);
const handleFileSaveClick = React.useCallback(() => {
// wait for context menu to close
setTimeout(() => {
if (chartRef.current) {
domToImage.toPng(chartRef.current,
{
quality: 100,
bgcolor: pngBackgroundColor,
width: chartRef.current.offsetWidth * DOWNLOAD_IMAGE_SCALE,
height: chartRef.current.offsetHeight * DOWNLOAD_IMAGE_SCALE,
filter: (node) => node.nodeName !== 'BUTTON',
style: {
borderColor: 'transparent',
transform: `scale(${ DOWNLOAD_IMAGE_SCALE })`,
'transform-origin': 'top left',
},
})
.then((dataUrl) => {
const link = document.createElement('a');
link.download = `${ title } (Blockscout chart).png`;
link.href = dataUrl;
link.click();
link.remove();
});
}
}, 100);
}, [ pngBackgroundColor, title, chartRef ]);
const handleSVGSavingClick = React.useCallback(() => {
if (items) {
const headerRows = [
'Date', 'Value',
];
const dataRows = items.map((item) => [
dayjs(item.date).format('YYYY-MM-DD'), String(item.value),
]);
saveAsCSV(headerRows, dataRows, `${ title } (Blockscout stats)`);
}
}, [ items, title ]);
// TS thinks window.navigator.share can't be undefined, but it can
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const hasShare = isInBrowser && (window.navigator.share as any);
const handleShare = React.useCallback(async() => {
try {
await window.navigator.share({
title: title,
text: description,
url: chartUrl,
});
} catch (error) {}
}, [ title, description, chartUrl ]);
return (
<>
<Menu>
<Skeleton isLoaded={ !isLoading } borderRadius="base">
<MenuButton
w="36px"
h="32px"
icon={ <IconSvg name="dots" boxSize={ 4 } transform="rotate(-90deg)"/> }
colorScheme="gray"
variant="simple"
as={ IconButton }
>
<VisuallyHidden>
Open chart options menu
</VisuallyHidden>
</MenuButton>
</Skeleton>
<MenuList>
{ chartUrl && (
<MenuItem
display="flex"
alignItems="center"
onClick={ hasShare ? handleShare : onCopy }
closeOnSelect={ hasShare ? false : true }
>
<IconSvg name={ hasShare ? 'share' : 'copy' } boxSize={ 5 } mr={ 3 }/>
{ hasShare ? 'Share' : 'Copy link' }
</MenuItem>
) }
<MenuItem
display="flex"
alignItems="center"
onClick={ showChartFullscreen }
>
<IconSvg name="scope" boxSize={ 5 } mr={ 3 }/>
View fullscreen
</MenuItem>
<MenuItem
display="flex"
alignItems="center"
onClick={ handleFileSaveClick }
>
<IconSvg name="files/image" boxSize={ 5 } mr={ 3 }/>
Save as PNG
</MenuItem>
<MenuItem
display="flex"
alignItems="center"
onClick={ handleSVGSavingClick }
>
<IconSvg name="files/csv" boxSize={ 5 } mr={ 3 }/>
Save as CSV
</MenuItem>
</MenuList>
</Menu>
{ items && isFullscreen && (
<FullscreenChartModal
isOpen
items={ items }
title={ title }
description={ description }
onClose={ clearFullscreenChart }
units={ units }
resolution={ resolution }
zoomRange={ zoomRange }
handleZoom={ handleZoom }
handleZoomReset={ handleZoomReset }
/>
) }
</>
);
};
export default ChartMenu;
import { Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { Resolution } from '@blockscout/stats-types';
import { STATS_RESOLUTIONS } from 'ui/stats/constants';
import StatsDropdownMenu from 'ui/stats/StatsDropdownMenu';
type Props = {
resolution: Resolution;
resolutions: Array<string>;
onResolutionChange: (resolution: Resolution) => void;
isLoading?: boolean;
}
const ChartResolutionSelect = ({ resolution, resolutions, onResolutionChange, isLoading }: Props) => {
return (
<Skeleton borderRadius="base" isLoaded={ !isLoading } w={{ base: 'auto', lg: '160px' }}>
<StatsDropdownMenu
items={ STATS_RESOLUTIONS.filter(r => resolutions.includes(r.id)) }
selectedId={ resolution }
onSelect={ onResolutionChange }
/>
</Skeleton>
);
};
export default React.memo(ChartResolutionSelect);
import * as d3 from 'd3';
import React from 'react';
import { Resolution } from '@blockscout/stats-types';
import type { TimeChartData } from 'ui/shared/chart/types';
import ChartTooltipBackdrop, { useRenderBackdrop } from './tooltip/ChartTooltipBackdrop';
......@@ -21,9 +22,21 @@ interface Props {
yScale: d3.ScaleLinear<number, number>;
anchorEl: SVGRectElement | null;
noAnimation?: boolean;
resolution?: Resolution;
}
const ChartTooltip = ({ xScale, yScale, width, tooltipWidth = 200, height, data, anchorEl, noAnimation, ...props }: Props) => {
const ChartTooltip = ({
xScale,
yScale,
width,
tooltipWidth = 200,
height,
data,
anchorEl,
noAnimation,
resolution,
...props
}: Props) => {
const ref = React.useRef<SVGGElement>(null);
const trackerId = React.useRef<number>();
const isVisible = React.useRef(false);
......@@ -150,8 +163,8 @@ const ChartTooltip = ({ xScale, yScale, width, tooltipWidth = 200, height, data,
{ data.map(({ name }) => <ChartTooltipPoint key={ name }/>) }
<ChartTooltipContent>
<ChartTooltipBackdrop/>
<ChartTooltipTitle/>
<ChartTooltipRow label="Date" lineNum={ 1 }/>
<ChartTooltipTitle resolution={ resolution }/>
<ChartTooltipRow label={ getDateLabel(resolution) } lineNum={ 1 }/>
{ data.map(({ name }, index) => <ChartTooltipRow key={ name } label={ name } lineNum={ index + 1 }/>) }
</ChartTooltipContent>
</g>
......@@ -159,3 +172,16 @@ const ChartTooltip = ({ xScale, yScale, width, tooltipWidth = 200, height, data,
};
export default React.memo(ChartTooltip);
function getDateLabel(resolution?: Resolution): string {
switch (resolution) {
case Resolution.WEEK:
return 'Dates';
case Resolution.MONTH:
return 'Month';
case Resolution.YEAR:
return 'Year';
default:
return 'Date';
}
}
import type { IconProps } from '@chakra-ui/react';
import { Icon, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
// eslint-disable-next-line no-restricted-imports
import logoIcon from 'icons/networks/logo-placeholder.svg';
const ChartWatermarkIcon = (props: IconProps) => {
const watermarkColor = useColorModeValue('link', 'white');
return (
<Icon
{ ...props }
as={ logoIcon }
position="absolute"
opacity={ 0.1 }
top="50%"
left="50%"
transform="translate(-50%, -50%)"
pointerEvents="none"
viewBox="0 0 114 20"
color={ watermarkColor }
/>
);
};
export default ChartWatermarkIcon;
This diff is collapsed.
import { Box, Center, Flex, Link, Skeleton, Text } from '@chakra-ui/react';
import React from 'react';
import type { TimeChartItem } from './types';
import type { Resolution } from '@blockscout/stats-types';
import { apos } from 'lib/html-entities';
import ChartWatermarkIcon from './ChartWatermarkIcon';
import ChartWidgetGraph from './ChartWidgetGraph';
export type Props = {
items?: Array<TimeChartItem>;
title: string;
units?: string;
isLoading?: boolean;
isError?: boolean;
emptyText?: string;
zoomRange?: [ Date, Date ];
handleZoom: (range: [ Date, Date ]) => void;
isEnlarged?: boolean;
noAnimation?: boolean;
resolution?: Resolution;
}
const ChartWidgetContent = ({
items,
title,
isLoading,
isError,
units,
emptyText,
zoomRange,
handleZoom,
isEnlarged,
noAnimation,
resolution,
}: Props) => {
const hasItems = items && items.length > 2;
if (isError) {
return (
<Flex
alignItems="center"
justifyContent="center"
flexGrow={ 1 }
py={ 4 }
>
<Text
variant="secondary"
fontSize="sm"
textAlign="center"
>
{ `The data didn${ apos }t load. Please, ` }
<Link href={ window.document.location.href }>try to reload the page.</Link>
</Text>
</Flex>
);
}
if (isLoading) {
return <Skeleton flexGrow={ 1 } w="100%"/>;
}
if (!hasItems) {
return (
<Center flexGrow={ 1 }>
<Text variant="secondary" fontSize="sm">{ emptyText || 'No data' }</Text>
</Center>
);
}
return (
<Box flexGrow={ 1 } maxW="100%" position="relative" h="100%">
<ChartWidgetGraph
items={ items }
zoomRange={ zoomRange }
onZoom={ handleZoom }
title={ title }
units={ units }
isEnlarged={ isEnlarged }
noAnimation={ noAnimation }
resolution={ resolution }
/>
<ChartWatermarkIcon w="162px" h="15%"/>
</Box>
);
};
export default React.memo(ChartWidgetContent);
......@@ -2,9 +2,9 @@ import { useToken } from '@chakra-ui/react';
import * as d3 from 'd3';
import React from 'react';
import { Resolution } from '@blockscout/stats-types';
import type { ChartMargin, TimeChartData, TimeChartItem } from 'ui/shared/chart/types';
import dayjs from 'lib/date/dayjs';
import useIsMobile from 'lib/hooks/useIsMobile';
import ChartArea from 'ui/shared/chart/ChartArea';
import ChartAxis from 'ui/shared/chart/ChartAxis';
......@@ -20,37 +20,42 @@ interface Props {
title: string;
units?: string;
items: Array<TimeChartItem>;
onZoom: () => void;
isZoomResetInitial: boolean;
zoomRange?: [ Date, Date ];
onZoom: (range: [ Date, Date ]) => void;
margin?: ChartMargin;
noAnimation?: boolean;
resolution?: Resolution;
}
// temporarily turn off the data aggregation, we need a better algorithm for that
const MAX_SHOW_ITEMS = 100_000_000_000;
const DEFAULT_CHART_MARGIN = { bottom: 20, left: 10, right: 20, top: 10 };
const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title, margin: marginProps, units, noAnimation }: Props) => {
const ChartWidgetGraph = ({
isEnlarged,
items,
onZoom,
title,
margin: marginProps,
units,
noAnimation,
resolution,
zoomRange,
}: Props) => {
const isMobile = useIsMobile();
const color = useToken('colors', 'blue.200');
const chartId = `chart-${ title.split(' ').join('') }-${ isEnlarged ? 'fullscreen' : 'small' }`;
const overlayRef = React.useRef<SVGRectElement>(null);
const [ range, setRange ] = React.useState<[ Date, Date ]>([ items[0].date, items[items.length - 1].date ]);
const range = React.useMemo(() => zoomRange || [ items[0].date, items[items.length - 1].date ], [ zoomRange, items ]);
const rangedItems = React.useMemo(() =>
items.filter((item) => item.date >= range[0] && item.date <= range[1]),
[ items, range ]);
const isGroupedValues = rangedItems.length > MAX_SHOW_ITEMS;
const displayedData = React.useMemo(() => {
if (isGroupedValues) {
return groupChartItemsByWeekNumber(rangedItems);
} else {
return rangedItems;
}
}, [ isGroupedValues, rangedItems ]);
const displayedData = React.useMemo(() =>
items
.filter((item) => item.date >= range[0] && item.date <= range[1])
.map((item) => ({
...item,
dateLabel: getDateLabel(item.date, item.date_to, resolution),
})),
[ items, range, resolution ]);
const chartData: TimeChartData = React.useMemo(() => ([ { items: displayedData, name: 'Value', color, units } ]), [ color, displayedData, units ]);
......@@ -80,17 +85,6 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
axesConfig,
});
const handleRangeSelect = React.useCallback((nextRange: [ Date, Date ]) => {
setRange([ nextRange[0], nextRange[1] ]);
onZoom();
}, [ onZoom ]);
React.useEffect(() => {
if (isZoomResetInitial) {
setRange([ items[0].date, items[items.length - 1].date ]);
}
}, [ isZoomResetInitial, items ]);
return (
<svg width="100%" height="100%" ref={ ref } cursor="pointer" id={ chartId } opacity={ rect ? 1 : 0 }>
......@@ -143,12 +137,13 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
<ChartTooltip
anchorEl={ overlayRef.current }
width={ innerWidth }
tooltipWidth={ isGroupedValues ? 280 : 200 }
tooltipWidth={ (resolution === Resolution.WEEK) ? 280 : 200 }
height={ innerHeight }
xScale={ axes.x.scale }
yScale={ axes.y.scale }
data={ chartData }
noAnimation={ noAnimation }
resolution={ resolution }
/>
<ChartSelectionX
......@@ -156,7 +151,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
height={ innerHeight }
scale={ axes.x.scale }
data={ chartData }
onSelect={ handleRangeSelect }
onSelect={ onZoom }
/>
</ChartOverlay>
</g>
......@@ -166,13 +161,15 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
export default React.memo(ChartWidgetGraph);
function groupChartItemsByWeekNumber(items: Array<TimeChartItem>): Array<TimeChartItem> {
return d3.rollups(items,
(group) => ({
date: group[0].date,
value: d3.sum(group, (d) => d.value),
dateLabel: `${ d3.timeFormat('%e %b %Y')(group[0].date) }${ d3.timeFormat('%e %b %Y')(group[group.length - 1].date) }`,
}),
(t) => `${ dayjs(t.date).week() } / ${ dayjs(t.date).year() }`,
).map(([ , v ]) => v);
function getDateLabel(date: Date, dateTo?: Date, resolution?: Resolution): string {
switch (resolution) {
case Resolution.WEEK:
return d3.timeFormat('%e %b %Y')(date) + (dateTo ? ` – ${ d3.timeFormat('%e %b %Y')(dateTo) }` : '');
case Resolution.MONTH:
return d3.timeFormat('%b %Y')(date);
case Resolution.YEAR:
return d3.timeFormat('%Y')(date);
default:
return d3.timeFormat('%e %b %Y')(date);
}
}
import { Box, Button, Grid, Heading, Modal, ModalBody, ModalCloseButton, ModalContent, ModalOverlay, Text } from '@chakra-ui/react';
import React, { useCallback } from 'react';
import React from 'react';
import type { TimeChartItem } from './types';
import type { Resolution } from '@blockscout/stats-types';
import IconSvg from 'ui/shared/IconSvg';
import ChartWidgetGraph from './ChartWidgetGraph';
import ChartWidgetContent from './ChartWidgetContent';
type Props = {
isOpen: boolean;
......@@ -14,6 +15,10 @@ type Props = {
items: Array<TimeChartItem>;
onClose: () => void;
units?: string;
resolution?: Resolution;
zoomRange?: [ Date, Date ];
handleZoom: (range: [ Date, Date ]) => void;
handleZoomReset: () => void;
}
const FullscreenChartModal = ({
......@@ -23,17 +28,11 @@ const FullscreenChartModal = ({
items,
units,
onClose,
resolution,
zoomRange,
handleZoom,
handleZoomReset,
}: Props) => {
const [ isZoomResetInitial, setIsZoomResetInitial ] = React.useState(true);
const handleZoom = useCallback(() => {
setIsZoomResetInitial(false);
}, []);
const handleZoomResetClick = useCallback(() => {
setIsZoomResetInitial(true);
}, []);
return (
<Modal
isOpen={ isOpen }
......@@ -69,7 +68,7 @@ const FullscreenChartModal = ({
</Text>
) }
{ !isZoomResetInitial && (
{ Boolean(zoomRange) && (
<Button
leftIcon={ <IconSvg name="repeat" w={ 4 } h={ 4 }/> }
colorScheme="blue"
......@@ -79,7 +78,7 @@ const FullscreenChartModal = ({
gridRow="1/3"
size="sm"
variant="outline"
onClick={ handleZoomResetClick }
onClick={ handleZoomReset }
>
Reset zoom
</Button>
......@@ -91,15 +90,16 @@ const FullscreenChartModal = ({
<ModalBody
h="100%"
margin={{ bottom: 60 }}
>
<ChartWidgetGraph
margin={{ bottom: 60 }}
<ChartWidgetContent
isEnlarged
items={ items }
units={ units }
onZoom={ handleZoom }
isZoomResetInitial={ isZoomResetInitial }
handleZoom={ handleZoom }
zoomRange={ zoomRange }
title={ title }
resolution={ resolution }
/>
</ModalBody>
</ModalContent>
......
......@@ -2,10 +2,15 @@ import { useToken } from '@chakra-ui/react';
import * as d3 from 'd3';
import React from 'react';
import { Resolution } from '@blockscout/stats-types';
import { STATS_RESOLUTIONS } from 'ui/stats/constants';
import ChartTooltipRow from './ChartTooltipRow';
const ChartTooltipTitle = () => {
const ChartTooltipTitle = ({ resolution = Resolution.DAY }: { resolution?: Resolution }) => {
const titleColor = useToken('colors', 'yellow.300');
const resolutionTitle = STATS_RESOLUTIONS.find(r => r.id === resolution)?.title || 'day';
return (
<ChartTooltipRow lineNum={ 0 }>
......@@ -16,7 +21,7 @@ const ChartTooltipTitle = () => {
opacity={ 0 }
dominantBaseline="hanging"
>
Incomplete day
{ `Incomplete ${ resolutionTitle.toLowerCase() }` }
</text>
</ChartTooltipRow>
);
......
......@@ -6,6 +6,7 @@ export interface TimeChartItemRaw {
export interface TimeChartItem {
date: Date;
date_to?: Date;
dateLabel?: string;
value: number;
isApproximate?: boolean;
......
import React from 'react';
import type { LineChart, Resolution } from '@blockscout/stats-types';
import type { StatsIntervalIds } from 'types/client/stats';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app';
import { STATS_INTERVALS } from 'ui/stats/constants';
import formatDate from './utils/formatIntervalDate';
export default function useChartQuery(id: string, resolution: Resolution, interval: StatsIntervalIds, enabled = true) {
const { apiData } = useAppContext<'/stats/[id]'>();
const selectedInterval = STATS_INTERVALS[interval];
const endDate = selectedInterval.start ? formatDate(new Date()) : undefined;
const startDate = selectedInterval.start ? formatDate(selectedInterval.start) : undefined;
const [ info, setInfo ] = React.useState<LineChart['info']>(apiData || undefined);
const lineQuery = useApiQuery('stats_line', {
pathParams: { id },
queryParams: {
from: startDate,
to: endDate,
resolution,
},
queryOptions: {
enabled: enabled,
refetchOnMount: false,
placeholderData: {
info: {
title: 'Chart title placeholder',
description: 'Chart placeholder description chart placeholder description',
resolutions: [ 'DAY', 'WEEK', 'MONTH', 'YEAR' ],
id: 'placeholder',
units: undefined,
},
chart: [],
},
},
});
React.useEffect(() => {
if (!info && lineQuery.data?.info && !lineQuery.isPlaceholderData) {
// save info to keep title and description when change query params
setInfo(lineQuery.data?.info);
}
}, [ info, lineQuery.data?.info, lineQuery.isPlaceholderData ]);
const items = React.useMemo(() => lineQuery.data?.chart?.map((item) => {
return { date: new Date(item.date), date_to: new Date(item.date_to), value: Number(item.value), isApproximate: item.is_approximate };
}), [ lineQuery ]);
return {
items,
info,
lineQuery,
};
}
import React from 'react';
export default function useZoom() {
const [ isZoomResetInitial, setIsZoomResetInitial ] = React.useState(true);
const [ zoomRange, setZoomRange ] = React.useState<[ Date, Date ] | undefined>();
const handleZoom = React.useCallback((range: [ Date, Date ]) => {
setZoomRange(range);
setIsZoomResetInitial(false);
}, []);
const handleZoomReset = React.useCallback(() => {
setZoomRange(undefined);
setIsZoomResetInitial(true);
}, []);
return {
isZoomResetInitial,
zoomRange,
handleZoom,
handleZoomReset,
};
}
export default function formatDate(date: Date) {
return date.toISOString().substring(0, 10);
}
import type { TagProps } from '@chakra-ui/react';
import { HStack, Tag } from '@chakra-ui/react';
import React from 'react';
type Props<T extends string> = {
items: Array<{ id: T; title: string }>;
tagSize?: TagProps['size'];
} & (
{
value: T;
......@@ -15,7 +17,7 @@ type Props<T extends string> = {
}
)
const TagGroupSelect = <T extends string>({ items, value, isMulti, onChange }: Props<T>) => {
const TagGroupSelect = <T extends string>({ items, value, isMulti, onChange, tagSize }: Props<T>) => {
const onItemClick = React.useCallback((event: React.SyntheticEvent) => {
const itemValue = (event.currentTarget as HTMLDivElement).getAttribute('data-id') as T;
if (isMulti) {
......@@ -44,6 +46,9 @@ const TagGroupSelect = <T extends string>({ items, value, isMulti, onChange }: P
fontWeight={ 500 }
cursor="pointer"
onClick={ onItemClick }
size={ tagSize }
display="inline-flex"
justifyContent="center"
>
{ item.title }
</Tag>
......
import { chakra } from '@chakra-ui/react';
import React, { useEffect, useMemo } from 'react';
import React, { useEffect } from 'react';
import { Resolution } from '@blockscout/stats-types';
import type { StatsIntervalIds } from 'types/client/stats';
import useApiQuery from 'lib/api/useApiQuery';
import type { Route } from 'nextjs-routes';
import useChartQuery from 'ui/shared/chart/useChartQuery';
import ChartWidget from '../shared/chart/ChartWidget';
import { STATS_INTERVALS } from './constants';
type Props = {
id: string;
......@@ -17,50 +19,39 @@ type Props = {
onLoadingError: () => void;
isPlaceholderData: boolean;
className?: string;
href?: Route;
}
function formatDate(date: Date) {
return date.toISOString().substring(0, 10);
}
const ChartWidgetContainer = ({ id, title, description, interval, onLoadingError, units, isPlaceholderData, className }: Props) => {
const selectedInterval = STATS_INTERVALS[interval];
const endDate = selectedInterval.start ? formatDate(new Date()) : undefined;
const startDate = selectedInterval.start ? formatDate(selectedInterval.start) : undefined;
const { data, isPending, isError } = useApiQuery('stats_line', {
pathParams: { id },
queryParams: {
from: startDate,
to: endDate,
},
queryOptions: {
enabled: !isPlaceholderData,
refetchOnMount: false,
},
});
const items = useMemo(() => data?.chart?.map((item) => {
return { date: new Date(item.date), value: Number(item.value), isApproximate: item.is_approximate };
}), [ data ]);
const ChartWidgetContainer = ({
id,
title,
description,
interval,
onLoadingError,
units,
isPlaceholderData,
className,
href,
}: Props) => {
const { items, lineQuery } = useChartQuery(id, Resolution.DAY, interval, !isPlaceholderData);
useEffect(() => {
if (isError) {
if (lineQuery.isError) {
onLoadingError();
}
}, [ isError, onLoadingError ]);
}, [ lineQuery.isError, onLoadingError ]);
return (
<ChartWidget
isError={ isError }
isError={ lineQuery.isError }
items={ items }
title={ title }
units={ units }
description={ description }
isLoading={ isPending }
isLoading={ lineQuery.isPlaceholderData }
minH="230px"
className={ className }
href={ href }
/>
);
};
......
......@@ -95,6 +95,7 @@ const ChartsWidgetsList = ({ filterQuery, isError, isPlaceholderData, charts, in
units={ chart.units || undefined }
isPlaceholderData={ isPlaceholderData }
onLoadingError={ handleChartLoadingError }
href={{ pathname: '/stats/[id]', query: { id: chart.id } }}
/>
)) }
</Grid>
......
......@@ -5,7 +5,7 @@ import Menu from 'ui/shared/chakra/Menu';
import IconSvg from 'ui/shared/IconSvg';
type Props<T extends string> = {
items: Array<{id: T; title: string}>;
items: ReadonlyArray<{id: T; title: string}>;
selectedId: T;
onSelect: (id: T) => void;
}
......@@ -23,7 +23,7 @@ export function StatsDropdownMenu<T extends string>({ items, selectedId, onSelec
>
<MenuButton
as={ Button }
size="md"
size="sm"
variant="outline"
colorScheme="gray"
w="100%"
......
......@@ -2,18 +2,13 @@ import { Grid, GridItem, Skeleton } from '@chakra-ui/react';
import React from 'react';
import type * as stats from '@blockscout/stats-types';
import type { StatsInterval, StatsIntervalIds } from 'types/client/stats';
import type { StatsIntervalIds } from 'types/client/stats';
import ChartIntervalSelect from 'ui/shared/chart/ChartIntervalSelect';
import FilterInput from 'ui/shared/filters/FilterInput';
import { STATS_INTERVALS } from './constants';
import StatsDropdownMenu from './StatsDropdownMenu';
const intervalList = Object.keys(STATS_INTERVALS).map((id: string) => ({
id: id,
title: STATS_INTERVALS[id as StatsIntervalIds].title,
})) as Array<StatsInterval>;
type Props = {
sections?: Array<stats.LineChartSection>;
currentSection: string;
......@@ -37,24 +32,25 @@ const StatsFilters = ({
}: Props) => {
const sectionsList = [ {
id: 'all',
title: 'All',
title: 'All stats',
}, ... (sections || []) ];
return (
<Grid
gap={ 2 }
gap={{ base: 2, lg: 6 }}
templateAreas={{
base: `"section interval"
"input input"`,
lg: `"section interval input"`,
}}
gridTemplateColumns={{ base: 'repeat(2, minmax(0, 1fr))', lg: 'auto auto 1fr' }}
alignItems="center"
>
<GridItem
w={{ base: '100%', lg: 'auto' }}
area="section"
>
{ isLoading ? <Skeleton w={{ base: '100%', lg: '76px' }} h="40px" borderRadius="base"/> : (
{ isLoading ? <Skeleton w={{ base: '100%', lg: '103px' }} h="32px" borderRadius="base"/> : (
<StatsDropdownMenu
items={ sectionsList }
selectedId={ currentSection }
......@@ -67,13 +63,7 @@ const StatsFilters = ({
w={{ base: '100%', lg: 'auto' }}
area="interval"
>
{ isLoading ? <Skeleton w={{ base: '100%', lg: '118px' }} h="40px" borderRadius="base"/> : (
<StatsDropdownMenu
items={ intervalList }
selectedId={ interval }
onSelect={ onIntervalChange }
/>
) }
<ChartIntervalSelect interval={ interval } onIntervalChange={ onIntervalChange } isLoading={ isLoading } selectTagSize="md"/>
</GridItem>
<GridItem
......@@ -86,6 +76,7 @@ const StatsFilters = ({
onChange={ onFilterInputChange }
placeholder="Find chart, metric..."
initialValue={ initialFilterValue }
size="xs"
/>
</GridItem>
</Grid>
......
import { Resolution } from '@blockscout/stats-types';
import type { StatsIntervalIds } from 'types/client/stats';
export const STATS_INTERVALS: { [key in StatsIntervalIds]: { title: string; start?: Date } } = {
export const STATS_RESOLUTIONS: Array<{id: Resolution; title: string }> = [
{
id: Resolution.DAY,
title: 'Day',
},
{
id: Resolution.WEEK,
title: 'Week',
},
{
id: Resolution.MONTH,
title: 'Month',
},
{
id: Resolution.YEAR,
title: 'Year',
},
];
export const STATS_INTERVALS: { [key in StatsIntervalIds]: { title: string; shortTitle: string; start?: Date } } = {
all: {
title: 'All time',
shortTitle: 'All time',
},
oneMonth: {
title: '1 month',
shortTitle: '1M',
start: getStartDateInPast(1),
},
threeMonths: {
title: '3 months',
shortTitle: '3M',
start: getStartDateInPast(3),
},
sixMonths: {
title: '6 months',
shortTitle: '6M',
start: getStartDateInPast(6),
},
oneYear: {
title: '1 year',
shortTitle: '1Y',
start: getStartDateInPast(12),
},
};
......
......@@ -46,7 +46,7 @@ const TxsStats = () => {
value={ Number(txsStatsQuery.data?.transactions_count_24h).toLocaleString() }
period="24h"
isLoading={ txsStatsQuery.isPlaceholderData }
href={ config.features.stats.isEnabled ? { pathname: '/stats', query: { chartId: 'newTxns' } } : undefined }
href={ config.features.stats.isEnabled ? { pathname: '/stats/[id]', query: { id: 'newTxns' } } : undefined }
/>
<StatsWidget
label="Pending transactions"
......@@ -63,7 +63,7 @@ const TxsStats = () => {
valuePostfix={ thinsp + config.chain.currency.symbol }
period="24h"
isLoading={ txsStatsQuery.isPlaceholderData }
href={ config.features.stats.isEnabled ? { pathname: '/stats', query: { chartId: 'txnsFee' } } : undefined }
href={ config.features.stats.isEnabled ? { pathname: '/stats/[id]', query: { id: 'txnsFee' } } : undefined }
/>
<StatsWidget
label="Avg. transaction fee"
......@@ -72,7 +72,7 @@ const TxsStats = () => {
valuePostfix={ txFeeAvg.usd ? undefined : thinsp + config.chain.currency.symbol }
period="24h"
isLoading={ txsStatsQuery.isPlaceholderData }
href={ config.features.stats.isEnabled ? { pathname: '/stats', query: { chartId: 'averageTxnFee' } } : undefined }
href={ config.features.stats.isEnabled ? { pathname: '/stats/[id]', query: { id: 'averageTxnFee' } } : undefined }
/>
</Box>
);
......
......@@ -25,7 +25,8 @@ const VerifiedContractsCounters = () => {
diff={ countersQuery.data.new_smart_contracts_24h }
diffFormatted={ Number(countersQuery.data.new_smart_contracts_24h).toLocaleString() }
isLoading={ countersQuery.isPlaceholderData }
href={ config.features.stats.isEnabled ? { pathname: '/stats', query: { chartId: 'contractsGrowth' } } : undefined }
// there is no stats for contracts growth for now
// href={ config.features.stats.isEnabled ? { pathname: '/stats/[id]', query: { id: 'contractsGrowth' } } : undefined }
/>
<StatsWidget
label="Verified contracts"
......@@ -33,7 +34,7 @@ const VerifiedContractsCounters = () => {
diff={ countersQuery.data.new_verified_smart_contracts_24h }
diffFormatted={ Number(countersQuery.data.new_verified_smart_contracts_24h).toLocaleString() }
isLoading={ countersQuery.isPlaceholderData }
href={ config.features.stats.isEnabled ? { pathname: '/stats', query: { chartId: 'verifiedContractsGrowth' } } : undefined }
href={ config.features.stats.isEnabled ? { pathname: '/stats/[id]', query: { id: 'verifiedContractsGrowth' } } : undefined }
/>
</Box>
);
......
......@@ -1327,10 +1327,10 @@
resolved "https://registry.yarnpkg.com/@blockscout/bens-types/-/bens-types-1.4.1.tgz#9182a79d9015b7fa2339edf0bfa3cd0c32045e66"
integrity sha512-TlZ1HVdZ2Cswm/CcvNoxS+Ydiht/YGaLo//PJR/UmkmihlEFoY4HfVJvVcUnOQXi+Si7FwJ486DPii889nTJsQ==
"@blockscout/stats-types@1.6.0":
version "1.6.0"
resolved "https://registry.yarnpkg.com/@blockscout/stats-types/-/stats-types-1.6.0.tgz#cdb27ab3d3cb1eef7b8b069c39d4e09afda1aec9"
integrity sha512-MzItYOsLa3zgoFzRgFAgg7gynSXG0w/GqHzg5BGHcBPbPSp/g7A6mMtyIchI6TnZxxnCwziHHvzmJFXz11emUg==
"@blockscout/stats-types@2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@blockscout/stats-types/-/stats-types-2.0.0.tgz#3805f8379b75377cde8a9ab76306af37bb735846"
integrity sha512-icYDsOHsDACjG/7VZhlV+1QRKSJOycblpswQ5Si0dqeWdOpbtmxSqolAS/z6C77d8p+uxZUCMjNa9otUCqn18A==
"@blockscout/visualizer-types@0.2.0":
version "0.2.0"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment