diff --git a/bun.lockb b/bun.lockb index d70c1121f..b56042612 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/website/astro.config.mjs b/packages/website/astro.config.mjs index 585f018b2..f49655971 100644 --- a/packages/website/astro.config.mjs +++ b/packages/website/astro.config.mjs @@ -20,5 +20,8 @@ export default defineConfig({ '../../node_modules/.prisma/client/index-browser.js', }, }, + ssr: { + noExternal: [/^d3.*$/, /^@nivo.*$/], + }, }, }); diff --git a/packages/website/package.json b/packages/website/package.json index dcecbd642..bcad4bc29 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -13,6 +13,7 @@ "@blitzkit/core": "workspace:*", "@nanostores/persistent": "^0.10.2", "@nanostores/react": "^0.7.3", + "@nivo/bar": "^0.87.0", "@radix-ui/colors": "^3.0.0", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/themes": "^3.1.3", diff --git a/packages/website/src/components/EChart/index.tsx b/packages/website/src/components/EChart/index.tsx deleted file mode 100644 index 0387b2078..000000000 --- a/packages/website/src/components/EChart/index.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { Box, Flex, Heading, type BoxProps } from '@radix-ui/themes'; -import type { ECharts, EChartsOption, SetOptionOpts } from 'echarts'; -import { getInstanceByDom, init } from 'echarts'; -import type { CSSProperties } from 'react'; -import { useEffect, useRef } from 'react'; -import { echartsTheme } from './theme'; - -export type EChartProps = BoxProps & { - option: EChartsOption; - style?: CSSProperties; - settings?: SetOptionOpts; -}; - -/** - * Thank you Maneet Goyal! Heavily modified. - * - * https://dev.to/manufac/using-apache-echarts-with-react-and-typescript-353k - */ -export function EChart({ - option, - settings, - title, - ...props -}: EChartProps): JSX.Element { - const chartRef = useRef(null); - - useEffect(() => { - // Initialize chart - let chart: ECharts | undefined; - if (chartRef.current !== null) { - chart = init(chartRef.current, echartsTheme, { renderer: 'svg' }); - } - - // Add chart resize listener - // ResizeObserver is leading to a bit janky UX - function resizeChart() { - chart?.resize(); - } - window.addEventListener('resize', resizeChart); - - // Return cleanup function - return () => { - chart?.dispose(); - window.removeEventListener('resize', resizeChart); - }; - }, []); - - useEffect(() => { - if (chartRef.current === null) return undefined; - - const chart = getInstanceByDom(chartRef.current); - - if (chart === undefined) return undefined; - - chart.setOption(option, settings); - }, [option, settings]); - - return ( - - {title && {title}} - - - ); -} diff --git a/packages/website/src/components/EChart/theme.ts b/packages/website/src/components/EChart/theme.ts deleted file mode 100644 index d4dff35d1..000000000 --- a/packages/website/src/components/EChart/theme.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { Var } from '../../core/radix/var'; - -export const echartsTheme = { - seriesCnt: '3', - backgroundColor: Var('color-background'), - titleColor: '#ff0000', - subtitleColor: '#dddddd', - textColorShow: false, - textColor: '#333', - markTextColor: '#293441', - color: [ - Var('accent-indicator'), - '#fc97af', - '#87f7cf', - '#f7f494', - '#72ccff', - '#f7c5a0', - '#d4a4eb', - '#d2f5a6', - '#76f2f2', - ], - borderColor: '#ccc', - borderWidth: 0, - visualMapColor: ['#fc97af', '#87f7cf'], - legendTextColor: '#999999', - kColor: '#fc97af', - kColor0: 'transparent', - kBorderColor: '#fc97af', - kBorderColor0: '#87f7cf', - kBorderWidth: '2', - lineWidth: '3', - symbolSize: '0', - symbol: 'circle', - symbolBorderWidth: '4', - lineSmooth: true, - graphLineWidth: '1', - graphLineColor: '#ffffff', - mapLabelColor: '#893448', - mapLabelColorE: 'rgb(137,52,72)', - mapBorderColor: '#999999', - mapBorderColorE: '#eb8146', - mapBorderWidth: 0.5, - mapBorderWidthE: 1, - mapAreaColor: '#f3f3f3', - mapAreaColorE: 'rgba(255,178,72,1)', - axes: [ - { - type: 'all', - name: '通用坐标轴', - axisLineShow: true, - axisLineColor: '#666666', - axisTickShow: false, - axisTickColor: '#333', - axisLabelShow: true, - axisLabelColor: '#aaaaaa', - splitLineShow: false, - splitLineColor: ['#e6e6e6'], - splitAreaShow: false, - splitAreaColor: ['rgba(250,250,250,0.05)', 'rgba(200,200,200,0.02)'], - }, - { - type: 'category', - name: '类目坐标轴', - axisLineShow: true, - axisLineColor: '#333', - axisTickShow: true, - axisTickColor: '#333', - axisLabelShow: true, - axisLabelColor: '#333', - splitLineShow: false, - splitLineColor: ['#ccc'], - splitAreaShow: false, - splitAreaColor: ['rgba(250,250,250,0.3)', 'rgba(200,200,200,0.3)'], - }, - { - type: 'value', - name: '数值坐标轴', - axisLineShow: true, - axisLineColor: '#333', - axisTickShow: true, - axisTickColor: '#333', - axisLabelShow: true, - axisLabelColor: '#333', - splitLineShow: true, - splitLineColor: ['#ccc'], - splitAreaShow: false, - splitAreaColor: ['rgba(250,250,250,0.3)', 'rgba(200,200,200,0.3)'], - }, - { - type: 'log', - name: '对数坐标轴', - axisLineShow: true, - axisLineColor: '#333', - axisTickShow: true, - axisTickColor: '#333', - axisLabelShow: true, - axisLabelColor: '#333', - splitLineShow: true, - splitLineColor: ['#ccc'], - splitAreaShow: false, - splitAreaColor: ['rgba(250,250,250,0.3)', 'rgba(200,200,200,0.3)'], - }, - { - type: 'time', - name: '时间坐标轴', - axisLineShow: true, - axisLineColor: '#333', - axisTickShow: true, - axisTickColor: '#333', - axisLabelShow: true, - axisLabelColor: '#333', - splitLineShow: true, - splitLineColor: ['#ccc'], - splitAreaShow: false, - splitAreaColor: ['rgba(250,250,250,0.3)', 'rgba(200,200,200,0.3)'], - }, - ], - axisSeperateSetting: false, - toolboxColor: '#999999', - toolboxEmphasisColor: '#666666', - tooltipAxisColor: '#cccccc', - tooltipAxisWidth: 1, - timelineLineColor: '#87f7cf', - timelineLineWidth: 1, - timelineItemColor: '#87f7cf', - timelineItemColorE: '#f7f494', - timelineCheckColor: '#fc97af', - timelineCheckBorderColor: '#fc97af', - timelineItemBorderWidth: 1, - timelineControlColor: '#87f7cf', - timelineControlBorderColor: '#87f7cf', - timelineControlBorderWidth: 0.5, - timelineLabelColor: '#87f7cf', - datazoomBackgroundColor: 'rgba(255,255,255,0)', - datazoomDataColor: 'rgba(114,204,255,1)', - datazoomFillColor: 'rgba(114,204,255,0.2)', - datazoomHandleColor: '#72ccff', - datazoomHandleWidth: '100', - datazoomLabelColor: '#333333', -}; diff --git a/packages/website/src/components/Nivo/ThemedBar.tsx b/packages/website/src/components/Nivo/ThemedBar.tsx new file mode 100644 index 000000000..1837cb1fe --- /dev/null +++ b/packages/website/src/components/Nivo/ThemedBar.tsx @@ -0,0 +1,18 @@ +import { + ResponsiveBar, + type BarDatum, + type ResponsiveBarSvgProps, +} from '@nivo/bar'; +import { nivoTheme } from '../../core/nivo/theme'; + +export function ThemedBar( + props: ResponsiveBarSvgProps, +) { + return ( + + ); +} diff --git a/packages/website/src/components/Performance/Charts/components/BattleDistributionAcrossTiers.tsx b/packages/website/src/components/Performance/Charts/components/BattleDistributionAcrossTiers.tsx deleted file mode 100644 index 2f5a53876..000000000 --- a/packages/website/src/components/Performance/Charts/components/BattleDistributionAcrossTiers.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { - TIERS, - TIER_ROMAN_NUMERALS, - fetchAverageDefinitions, - fetchTankDefinitions, -} from '@blitzkit/core'; -import { TankPerformanceEphemeral } from '../../../../stores/tankPerformanceEphemeral'; -import { EChart } from '../../../EChart'; - -const averageDefinitions = await fetchAverageDefinitions(); -const tankDefinitions = await fetchTankDefinitions(); -const averageDefinitionsArray = Object.entries(averageDefinitions.averages); - -export function BattleDistributionAcrossTiers() { - const playerCountPeriod = TankPerformanceEphemeral.use( - (state) => state.playerCountPeriod, - ); - - return ( - TIER_ROMAN_NUMERALS[tier]), - }, - yAxis: { - type: 'value', - }, - series: [ - { - data: TIERS.map((tier) => { - return averageDefinitionsArray.reduce( - (accumulator, [idString, entry]) => { - const id = Number(idString); - const tank = tankDefinitions.tanks[id]; - - if (tank === undefined || tank.tier !== tier) { - return accumulator; - } - - return ( - accumulator + - entry.mu.battles * entry.samples[playerCountPeriod] - ); - }, - 0, - ); - }), - type: 'bar', - }, - ], - }} - /> - ); -} diff --git a/packages/website/src/components/Performance/Charts/components/BattleDistributionAcrossTiersByClass.tsx b/packages/website/src/components/Performance/Charts/components/BattleDistributionAcrossTiersByClass.tsx deleted file mode 100644 index fea1bca49..000000000 --- a/packages/website/src/components/Performance/Charts/components/BattleDistributionAcrossTiersByClass.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { - TANK_CLASSES, - TIERS, - TIER_ROMAN_NUMERALS, - fetchAverageDefinitions, - fetchTankDefinitions, -} from '@blitzkit/core'; -import strings from '@blitzkit/core/lang/en-US.json'; -import { TankPerformanceEphemeral } from '../../../../stores/tankPerformanceEphemeral'; -import { EChart } from '../../../EChart'; - -const averageDefinitions = await fetchAverageDefinitions(); -const tankDefinitions = await fetchTankDefinitions(); -const averageDefinitionsArray = Object.entries(averageDefinitions.averages); - -export function BattleDistributionAcrossTiersByClass() { - const playerCountPeriod = TankPerformanceEphemeral.use( - (state) => state.playerCountPeriod, - ); - - return ( - TIER_ROMAN_NUMERALS[tier]), - }, - yAxis: { - type: 'value', - }, - legend: { - data: TANK_CLASSES.map((c) => strings.common.tank_class_short[c]), - }, - series: TANK_CLASSES.map((c) => ({ - name: strings.common.tank_class_short[c], - data: TIERS.map((tier) => { - return averageDefinitionsArray.reduce( - (accumulator, [idString, entry]) => { - const id = Number(idString); - const tank = tankDefinitions.tanks[id]; - - if ( - tank === undefined || - tank.tier !== tier || - tank.class !== c - ) { - return accumulator; - } - - return ( - accumulator + - entry.mu.battles * entry.samples[playerCountPeriod] - ); - }, - 0, - ); - }), - type: 'bar', - })), - }} - /> - ); -} diff --git a/packages/website/src/components/Performance/Charts/components/BattleDistributionAcrossTiersNivo.tsx b/packages/website/src/components/Performance/Charts/components/BattleDistributionAcrossTiersNivo.tsx new file mode 100644 index 000000000..5a1b196e2 --- /dev/null +++ b/packages/website/src/components/Performance/Charts/components/BattleDistributionAcrossTiersNivo.tsx @@ -0,0 +1,87 @@ +import { + fetchAverageDefinitions, + fetchTankDefinitions, + formatCompact, + TANK_CLASSES, + TankClass, + TIER_ROMAN_NUMERALS, + TIERS, +} from '@blitzkit/core'; +import strings from '@blitzkit/core/lang/en-US.json'; +import { Box, Flex, Heading } from '@radix-ui/themes'; +import { useAveragesExclusionRatio } from '../../../../hooks/useAveragesExclusionRatio'; +import { TankPerformanceEphemeral } from '../../../../stores/tankPerformanceEphemeral'; +import { ThemedBar } from '../../../Nivo/ThemedBar'; + +const averageDefinitions = await fetchAverageDefinitions(); +const tankDefinitions = await fetchTankDefinitions(); +const averageDefinitionsArray = Object.entries(averageDefinitions.averages); + +export function BattleDistributionAcrossTiers() { + const playerCountPeriod = TankPerformanceEphemeral.use( + (state) => state.playerCountPeriod, + ); + const ratio = useAveragesExclusionRatio(); + + return ( + + Players by tier & class + + { + const players: Record = { + 0: 0, + 1: 0, + 2: 0, + 3: 0, + }; + + averageDefinitionsArray.forEach(([idString, entry]) => { + const id = Number(idString); + const tank = tankDefinitions.tanks[id]; + + if (tank === undefined || tank.tier !== tier) return; + + players[tank.class] += entry.samples[playerCountPeriod] * ratio; + }); + + const bar: Record = { + tier: TIER_ROMAN_NUMERALS[tier], + }; + + TANK_CLASSES.forEach((c) => { + bar[strings.common.tank_class_short[c]] = players[c]; + }); + + return bar; + })} + keys={TANK_CLASSES.map((c) => strings.common.tank_class_short[c])} + indexBy="tier" + margin={{ right: 90, bottom: 50, left: 90 }} + axisBottom={{ + legend: 'Tier', + legendPosition: 'middle', + legendOffset: 32, + }} + axisLeft={{ + legend: 'Players', + legendPosition: 'middle', + legendOffset: -60, + format: formatCompact, + }} + legends={[ + { + dataFrom: 'keys', + anchor: 'bottom-right', + direction: 'column', + translateX: 120, + itemWidth: 100, + itemHeight: 20, + }, + ]} + valueFormat={formatCompact} + /> + + + ); +} diff --git a/packages/website/src/components/Performance/Charts/index.tsx b/packages/website/src/components/Performance/Charts/index.tsx index 2a9a0b56c..a30184d18 100644 --- a/packages/website/src/components/Performance/Charts/index.tsx +++ b/packages/website/src/components/Performance/Charts/index.tsx @@ -1,13 +1,11 @@ import { Flex } from '@radix-ui/themes'; -import { BattleDistributionAcrossTiers } from './components/BattleDistributionAcrossTiers'; -import { BattleDistributionAcrossTiersByClass } from './components/BattleDistributionAcrossTiersByClass'; +import { BattleDistributionAcrossTiers } from './components/BattleDistributionAcrossTiersNivo'; export function Charts() { return ( - ); diff --git a/packages/website/src/components/Performance/PlayerCountControl.tsx b/packages/website/src/components/Performance/PlayerCountControl.tsx index b0d9928cb..18c13e01e 100644 --- a/packages/website/src/components/Performance/PlayerCountControl.tsx +++ b/packages/website/src/components/Performance/PlayerCountControl.tsx @@ -1,7 +1,7 @@ import { Flex, Select, Text } from '@radix-ui/themes'; import { - PlayerCountPeriod, TankPerformanceEphemeral, + type PlayerCountPeriod, } from '../../stores/tankPerformanceEphemeral'; export function PlayerCountControl() { @@ -23,24 +23,13 @@ export function PlayerCountControl() { > - - Past 120 days - - - Past 90 days - - - Past 60 days - - - This month - - - This week - - - Yesterday - + Total + Past 120 days + Past 90 days + Past 60 days + This month + This week + Yesterday diff --git a/packages/website/src/core/nivo/theme.ts b/packages/website/src/core/nivo/theme.ts index 2fa0ae388..ba77a165d 100644 --- a/packages/website/src/core/nivo/theme.ts +++ b/packages/website/src/core/nivo/theme.ts @@ -1,10 +1,39 @@ import type { Theme } from '@nivo/core'; +import { Var } from '../radix/var'; export const nivoTheme = { - theme: { - text: { fill: 'var(--gray-11)' }, - grid: { line: { stroke: 'var(--gray-3)' } }, - } satisfies Theme, - borderRadius: 2, - innerPadding: 1, -}; + background: Var('color-background'), + text: { + fill: Var('gray-contrast'), + fontFamily: 'inherit', + }, + axis: { + ticks: { + text: { + fill: Var('gray-indicator'), + }, + line: { + stroke: Var('gray-indicator'), + }, + }, + }, + grid: { + line: { + stroke: Var('gray-indicator'), + }, + }, + tooltip: { + container: { + background: Var('color-overlay'), + backdropFilter: 'blur(4rem)', + WebkitBackdropFilter: 'blur(4rem)', + color: Var('gray-contrast'), + borderRadius: Var('radius-2'), + }, + }, + labels: { + text: { + fill: Var('gray-1'), + }, + }, +} satisfies Theme; diff --git a/packages/website/src/stores/tankPerformanceEphemeral.ts b/packages/website/src/stores/tankPerformanceEphemeral.ts index b4188367f..a22ceaffc 100644 --- a/packages/website/src/stores/tankPerformanceEphemeral.ts +++ b/packages/website/src/stores/tankPerformanceEphemeral.ts @@ -1,3 +1,4 @@ +import type { Samples } from '@blitzkit/core'; import { create } from 'zustand'; import { createContextualStore } from '../core/zustand/createContextualStore'; @@ -11,18 +12,11 @@ export enum TankPerformanceMode { Charts, } -export enum PlayerCountPeriod { - Past120Days = 'd_120', - Past90Days = 'd_90', - Past60Days = 'd_60', - ThisMonth = 'd_30', - ThisWeek = 'd_7', - Yesterday = 'd_1', -} +export type PlayerCountPeriod = keyof Samples; export const TankPerformanceEphemeral = createContextualStore(() => create()(() => ({ - playerCountPeriod: PlayerCountPeriod.ThisMonth, + playerCountPeriod: 'd_30', mode: TankPerformanceMode.Charts, })), );