diff --git a/src/locales/en.json b/src/locales/en.json index fe766623a..9aadc6ca1 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -292,6 +292,7 @@ "circulating_supply": "Circulating Supply", "burnt": "Burnt", "locked": "Unvested", + "week": "Time (week)", "year": "Time (year)", "nominal_apc": "Nominal DAO Compensation Rate", "inflation_rate": "Inflation Rate", @@ -326,6 +327,9 @@ "ckb_amount": "CKB Amount", "contract_resource_distributed": "Contract Resource Distribution", "contract_resource_distributed_description": "The x axis represents contract's unique address count, the y axis represents the contract's CKB amount, the symbol size represents the contract's transaction count.", + "active_addresses": "Active Addresses", + "active_addresses_description": "The number of unique addresses that have participated in the network as a sender or receiver.", + "active_address_count": "Active Addresses Count", "country": "Country/Region", "node_country_distribution": "Nodes distribution by Country/Region", "top_50_holders": "Top 50 Holders", @@ -340,7 +344,32 @@ "over_three_years": "> 3y", "ckb_hodl_wave": "CKB HODL Wave", "h24_transaction_count": "24hr Transaction Count", - "holder_count": "Holder Count" + "holder_count": "Holder Count", + "address_label": { + "anyoneCanPayLock": "Anyone Can Pay", + "btcTimeLock": "BTC Time", + "cheque": "Cheque", + "flashSigner": "Flash Signer", + "godwokenCustodianLock": "Godwoken Custodian", + "godwokenDepositLock": "Godwoken Deposit", + "godwokenStakeLock": "Godwoken Stake", + "godwokenWithdrawalLock": "Godwoken Withdrawal", + "iCkbLogic": "iCKB Logic", + "joyId": "Joy ID", + "nostr": "Nostr", + "omniLockV1": "Omni Lock V1", + "omniLockV2": "Omni Lock V2", + "pwLock": "Portal Wallet", + "rgb++": "RGB++", + "secp256K1/blake160": "Secp256k1/Blake160", + "secp256K1/multisig": "Secp256k1/Multisig", + "singleUseLock": "Single Use", + "udtLimitOrderr": "UDT Limit Order", + "unipassV2": "Unipass V2", + "unipassV3": "Unipass V3", + "wrOwnedOwner": "WR Owned Owner", + "others": "Others" + } }, "home": { "height": "Height", diff --git a/src/locales/zh.json b/src/locales/zh.json index 605431e21..cca1b5de6 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -306,6 +306,7 @@ "circulating_supply": "总流通量", "burnt": "销毁量", "locked": "未解锁数量", + "week": "时间 (周)", "year": "时间 (年)", "nominal_apc": "基准补偿率", "inflation_rate": "通胀率", @@ -340,6 +341,9 @@ "ckb_amount": "CKB 数量", "contract_resource_distributed": "合约资源分布图", "contract_resource_distributed_description": "横轴表示合约的唯一地址数量, 纵轴是合约的 CKB 数量, 图标大小代表合约的交易数量", + "active_addresses": "活跃地址", + "active_addresses_description": "活跃地址是指在特定时间段内有交易记录的地址", + "active_address_count": "活跃地址数量", "country": "国家(地区)", "node_country_distribution": "节点国家(地区)分布图", "top_50_holders": "前 50 持有地址", diff --git a/src/pages/StatisticsChart/activities/ActiveAddressesChart.tsx b/src/pages/StatisticsChart/activities/ActiveAddressesChart.tsx new file mode 100644 index 000000000..1cd52ede6 --- /dev/null +++ b/src/pages/StatisticsChart/activities/ActiveAddressesChart.tsx @@ -0,0 +1,151 @@ +import { useTranslation } from 'react-i18next' +import type { ChartColorConfig } from '../../../constants/common' +import { SmartChartPage } from '../common' +import { DATA_ZOOM_CONFIG, handleAxis, variantColors } from '../../../utils/chart' +import { explorerService } from '../../../services/ExplorerService' + +// Helper function to get ISO week number and year +function getWeekNumber(timestamp: string) { + const date = new Date(+timestamp * 1000) + const firstDayOfYear = new Date(date.getFullYear(), 0, 1) + const days = Math.floor((date.getTime() - firstDayOfYear.getTime()) / (24 * 60 * 60 * 1000)) + const weekNumber = Math.ceil((days + firstDayOfYear.getDay() + 1) / 7) + return `${date.getFullYear()}-W${weekNumber}` +} + +const useOption = ( + activeAddresses: { + createdAtUnixtimestamp: string + distribution: Record + }[], + _: ChartColorConfig, + isMobile: boolean, + isThumbnail = false, +): echarts.EChartOption => { + const { t } = useTranslation() + const gridThumbnail = { + left: '4%', + right: '10%', + top: '8%', + bottom: '6%', + containLabel: true, + } + const grid = { + left: '4%', + right: '8%', + top: '12%', + bottom: '5%', + containLabel: true, + } + + const aggregatedByWeek = activeAddresses.reduce((acc, item) => { + const week = getWeekNumber(item.createdAtUnixtimestamp) + + if (!acc[week]) { + acc[week] = { + createdAtWeek: week, + distribution: {}, + } + } + + Object.entries(item.distribution).forEach(([key, value]) => { + acc[week].distribution[key] = (acc[week].distribution[key] || 0) + value + }) + + return acc + }, {} as Record }>) + + const aggregatedDdata = Object.values(aggregatedByWeek) + const dataset = aggregatedDdata.slice(0, aggregatedDdata.length - 1) // Remove the last week data because it's not complete + const xAxisData = dataset.map(item => item.createdAtWeek) + const allKeys = Array.from(new Set(dataset.flatMap(item => Object.keys(item.distribution)))).sort((a, b) => { + if (a === 'others') return 1 + if (b === 'others') return -1 + return a.localeCompare(b) + }) + const series = allKeys.map(key => ({ + name: t(`statistic.address_label.${key}`), + type: 'line', + stack: 'total', + areaStyle: {}, + lineStyle: { + width: 0, + }, + symbol: 'none', + emphasis: { + focus: 'series', + }, + data: dataset.map(item => item.distribution[key] || 0), + })) + const colors = variantColors(allKeys.length) + + return { + color: colors, + tooltip: !isThumbnail + ? { + trigger: 'axis', + axisPointer: { type: 'cross' }, + formatter: params => { + // Filter out fields with value 0 + if (!Array.isArray(params)) return '' + const filteredParams = params.filter(item => item.value !== 0) + + // Construct the tooltip content + if (filteredParams.length === 0) return '' // No fields to display + + const header = `${filteredParams[0].axisValue}
` // Show week + const sum = ` +${t('statistic.active_address_count')}: ${filteredParams.reduce( + (acc, item) => acc + Number(item.value), + 0, + )}

` + const body = filteredParams + .map( + item => + ` + ${item.seriesName}: ${item.value}`, + ) + .join('
') + + return header + sum + body + }, + } + : undefined, + grid: isThumbnail ? gridThumbnail : grid, + dataZoom: isThumbnail ? [] : DATA_ZOOM_CONFIG, + legend: { data: isThumbnail ? [] : allKeys.map(key => t(`statistic.address_label.${key}`) as string) }, + xAxis: { + type: 'category', + boundaryGap: false, + data: xAxisData, + axisLabel: { + formatter: (value: string) => value, // Display week labels + }, + name: isMobile || isThumbnail ? '' : t('statistic.week'), + }, + yAxis: { + type: 'value', + name: isMobile || isThumbnail ? '' : `${t('statistic.active_address_count')}`, + axisLabel: { + formatter: (value: string) => handleAxis(+value), + }, + }, + series, + } +} + +export const ActiveAddressesChart = ({ isThumbnail = false }: { isThumbnail?: boolean }) => { + const [t] = useTranslation() + return ( + + ) +} + +export default ActiveAddressesChart diff --git a/src/pages/StatisticsChart/activities/CkbHodlWave.tsx b/src/pages/StatisticsChart/activities/CkbHodlWave.tsx index f168ed069..7bdac08a7 100644 --- a/src/pages/StatisticsChart/activities/CkbHodlWave.tsx +++ b/src/pages/StatisticsChart/activities/CkbHodlWave.tsx @@ -1,5 +1,6 @@ import { useTranslation } from 'react-i18next' import dayjs from 'dayjs' +import type { ChartColorConfig } from '../../../constants/common' import { SupportedLng, useCurrentLanguage } from '../../../utils/i18n' import { DATA_ZOOM_CONFIG, @@ -7,10 +8,10 @@ import { assertSerialsItem, assertSerialsDataIsStringArrayOf10, handleAxis, + variantColors, } from '../../../utils/chart' import { tooltipColor, tooltipWidth, SeriesItem, SmartChartPage } from '../common' import { ChartItem, explorerService } from '../../../services/ExplorerService' -import { ChartColorConfig } from '../../../constants/common' const widthSpan = (value: string, currentLanguage: SupportedLng) => tooltipWidth(value, currentLanguage === 'en' ? 125 : 80) @@ -82,7 +83,7 @@ const useTooltip = () => { const useOption = ( statisticCkbHodlWaves: ChartItem.CkbHodlWaveHolderCount[], - chartColor: ChartColorConfig, + _: ChartColorConfig, isMobile: boolean, isThumbnail = false, ): echarts.EChartOption => { @@ -103,7 +104,36 @@ const useOption = ( containLabel: true, } const parseTooltip = useTooltip() - const colors = [...chartColor.moreColors].slice(0, 9) + const legends = [ + { + name: t('statistic.24h'), + }, + { + name: t('statistic.day_to_one_week'), + }, + { + name: t('statistic.one_week_to_one_month'), + }, + { + name: t('statistic.one_month_to_three_months'), + }, + { + name: t('statistic.three_months_to_six_months'), + }, + { + name: t('statistic.six_months_to_one_year'), + }, + { + name: t('statistic.one_year_to_three_years'), + }, + { + name: t('statistic.over_three_years'), + }, + { + name: t('statistic.holder_count'), + }, + ] + const colors = variantColors(legends.length) return { color: colors, tooltip: !isThumbnail @@ -123,48 +153,7 @@ const useOption = ( } : undefined, legend: { - data: isThumbnail - ? [] - : [ - { - name: t('statistic.24h'), - }, - { - name: t('statistic.day_to_one_week'), - }, - { - name: t('statistic.one_week_to_one_month'), - }, - { - name: t('statistic.one_month_to_three_months'), - }, - { - name: t('statistic.three_months_to_six_months'), - }, - { - name: t('statistic.six_months_to_one_year'), - }, - { - name: t('statistic.one_year_to_three_years'), - }, - { - name: t('statistic.over_three_years'), - }, - { - name: t('statistic.holder_count'), - }, - ], - selected: { - [t('statistic.24h')]: true, - [t('statistic.day_to_one_week')]: true, - [t('statistic.one_week_to_one_month')]: true, - [t('statistic.one_month_to_three_months')]: true, - [t('statistic.three_months_to_six_months')]: true, - [t('statistic.six_months_to_one_year')]: true, - [t('statistic.one_year_to_three_years')]: true, - [t('statistic.over_three_years')]: true, - [t('statistic.holder_count')]: true, - }, + data: isThumbnail ? [] : legends, }, grid: isThumbnail ? gridThumbnail : grid, dataZoom: isThumbnail ? [] : DATA_ZOOM_CONFIG, @@ -214,6 +203,7 @@ const useOption = ( areaStyle: { color: colors[0], }, + lineStyle: { width: 0 }, }, { name: t('statistic.day_to_one_week'), @@ -225,6 +215,7 @@ const useOption = ( areaStyle: { color: colors[1], }, + lineStyle: { width: 0 }, }, { name: t('statistic.one_week_to_one_month'), @@ -236,6 +227,7 @@ const useOption = ( areaStyle: { color: colors[2], }, + lineStyle: { width: 0 }, }, { name: t('statistic.one_month_to_three_months'), @@ -247,6 +239,7 @@ const useOption = ( areaStyle: { color: colors[3], }, + lineStyle: { width: 0 }, }, { name: t('statistic.three_months_to_six_months'), @@ -258,6 +251,7 @@ const useOption = ( areaStyle: { color: colors[4], }, + lineStyle: { width: 0 }, }, { name: t('statistic.six_months_to_one_year'), @@ -269,6 +263,7 @@ const useOption = ( areaStyle: { color: colors[5], }, + lineStyle: { width: 0 }, }, { name: t('statistic.one_year_to_three_years'), @@ -280,6 +275,7 @@ const useOption = ( areaStyle: { color: colors[6], }, + lineStyle: { width: 0 }, }, { name: t('statistic.over_three_years'), @@ -291,6 +287,7 @@ const useOption = ( areaStyle: { color: colors[7], }, + lineStyle: { width: 0 }, }, { name: t('statistic.holder_count'), diff --git a/src/pages/StatisticsChart/index.tsx b/src/pages/StatisticsChart/index.tsx index c85c9e457..f54088bef 100644 --- a/src/pages/StatisticsChart/index.tsx +++ b/src/pages/StatisticsChart/index.tsx @@ -17,6 +17,7 @@ import { HashRateChart } from './mining/HashRate' import { UncleRateChart } from './mining/UncleRate' import { BalanceDistributionChart } from './activities/BalanceDistribution' import { ContractResourceDistributedChart } from './activities/ContractResourceDistributed' +import { ActiveAddressesChart } from './activities/ActiveAddressesChart' import { TxFeeHistoryChart } from './activities/TxFeeHistory' import { BlockTimeDistributionChart } from './block/BlockTimeDistribution' import { EpochTimeDistributionChart } from './block/EpochTimeDistribution' @@ -121,6 +122,12 @@ const useChartsData = () => { path: '/charts/contract-resource-distributed', description: t('statistic.contract_resource_distributed_description'), }, + { + title: `${t('statistic.active_addresses')}`, + chart: , + path: '/charts/active-addresses', + description: t('statistic.active_addresses_description'), + }, { title: `${t('statistic.knowledge_size')}`, chart: , diff --git a/src/routes/index.tsx b/src/routes/index.tsx index a88663c4a..f146eebc1 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -55,6 +55,7 @@ const CellCountChart = lazy(() => import('../pages/StatisticsChart/activities/Ce const ContractResourceDistributedChart = lazy( () => import('../pages/StatisticsChart/activities/ContractResourceDistributed'), ) +const ActiveAddressesChart = lazy(() => import('../pages/StatisticsChart/activities/ActiveAddressesChart')) const KnowledgeSizeChart = lazy(() => import('../pages/StatisticsChart/activities/KnowledgeSize')) const CkbHodlWaveChart = lazy(() => import('../pages/StatisticsChart/activities/CkbHodlWave')) const AddressBalanceRankChart = lazy(() => import('../pages/StatisticsChart/activities/AddressBalanceRank')) @@ -265,6 +266,10 @@ const routes: RouteProps[] = [ path: '/charts/contract-resource-distributed', component: ContractResourceDistributedChart, }, + { + path: '/charts/active-addresses', + component: ActiveAddressesChart, + }, { path: '/charts/knowledge-size', component: KnowledgeSizeChart, diff --git a/src/services/ExplorerService/fetcher.ts b/src/services/ExplorerService/fetcher.ts index 9f0ed87e8..34f98ee04 100644 --- a/src/services/ExplorerService/fetcher.ts +++ b/src/services/ExplorerService/fetcher.ts @@ -818,6 +818,18 @@ export const apiFetcher = { })) }), + fetchStatisticActiveAddresses: () => + v1GetUnwrappedList(`/daily_statistics/activity_address_contract_distribution`).then( + items => + items.map<{ + createdAtUnixtimestamp: string + distribution: Record + }>(({ createdAtUnixtimestamp, ...list }) => ({ + createdAtUnixtimestamp, + distribution: Object.assign({}, ...list.activityAddressContractDistribution), + })), + ), + fetchFlushChartCache: () => v1GetUnwrapped<{ flushCacheInfo: string[] }>(`statistics/flush_cache_info`), fetchSimpleUDT: (typeHash: string) => v1GetUnwrapped(`/udts/${typeHash}`), diff --git a/src/services/ExplorerService/types.ts b/src/services/ExplorerService/types.ts index 07f8399b3..24cb18357 100644 --- a/src/services/ExplorerService/types.ts +++ b/src/services/ExplorerService/types.ts @@ -201,6 +201,11 @@ export namespace ChartItem { createdAtUnixtimestamp: string knowledgeSize: number } + + export interface ActiveAddresses { + createdAtUnixtimestamp: string + activityAddressContractDistribution: Record[] + } } export interface NervosDaoDepositor { diff --git a/src/utils/chart.ts b/src/utils/chart.ts index 436664c57..df4ea3afd 100644 --- a/src/utils/chart.ts +++ b/src/utils/chart.ts @@ -1,5 +1,6 @@ import BigNumber from 'bignumber.js' import { EChartOption } from 'echarts' +import { ChartColor } from '../constants/common' import { SeriesItem } from '../pages/StatisticsChart/common' import type { FeeRateTracker } from '../services/ExplorerService/fetcher' @@ -210,3 +211,47 @@ export const assertSerialsDataIsStringArrayOf10: (value: EChartOption.Tooltip.Fo throw new Error('invalid SeriesItem length of 10') } } + +const BASE_COLORS = [ + ...ChartColor.colors.slice(0, 2), + '#FF5733', + '#FFC300', + '#DAF7A6', + '#33FF57', + '#33C1FF', + '#8A33FF', + '#FF33A8', + '#FF33F6', + '#FF8C33', + '#FFE733', +] + +export const variantColors = (count: number, baseColors: string[] = BASE_COLORS) => { + // Helper function to adjust brightness + function adjustColor(color: string, factor: number) { + const hex = color.replace('#', '') + const r = Math.min(255, Math.max(0, parseInt(hex.substring(0, 2), 16) + factor)) + const g = Math.min(255, Math.max(0, parseInt(hex.substring(2, 4), 16) + factor)) + const b = Math.min(255, Math.max(0, parseInt(hex.substring(4, 6), 16) + factor)) + return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}` + } + + const colors = [] + let variantIndex = 0 + + for (let i = 0; i < count; i++) { + const baseColor = baseColors[i % baseColors.length] + let adjustmentFactor = 0 + if (variantIndex % 3 === 1) { + adjustmentFactor = 30 + } else if (variantIndex % 3 === 2) { + variantIndex = -30 + } + colors.push(adjustColor(baseColor, adjustmentFactor)) + if ((i + 1) % baseColors.length === 0) { + variantIndex++ + } + } + + return colors +}