feat: nivo graphs

This commit is contained in:
TrèsAbhi committed 2024-10-22 15:08:23 -05:00
1 parent f2da3994e4
commit 5e6f0e5d18
13 files changed
+157 -364

No files matched your search

BIN
View File
Binary file not shown.
+3
View File
@@ -20,5 +20,8 @@ export default defineConfig({
'../../node_modules/.prisma/client/index-browser.js',
},
},
ssr: {
noExternal: [/^d3.*$/, /^@nivo.*$/],
},
},
});
+1
View File
@@ -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",
@@ -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<HTMLDivElement>(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 (
<Flex direction="column" align="center">
{title && <Heading align="center">{title}</Heading>}
<Box {...props} ref={chartRef} width="100%" />
</Flex>
);
}
@@ -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',
};
@@ -0,0 +1,18 @@
import {
ResponsiveBar,
type BarDatum,
type ResponsiveBarSvgProps,
} from '@nivo/bar';
import { nivoTheme } from '../../core/nivo/theme';
export function ThemedBar<RawDatum extends BarDatum>(
props: ResponsiveBarSvgProps<RawDatum>,
) {
return (
<ResponsiveBar
colors={{ scheme: 'yellow_green_blue' }}
theme={nivoTheme}
{...props}
/>
);
}
@@ -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 (
<EChart
height="25rem"
title="Battle distribution across tiers"
option={{
xAxis: {
type: 'category',
data: TIERS.map((tier) => 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',
},
],
}}
/>
);
}
@@ -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 (
<EChart
height="25rem"
title="Battle distribution across tiers by class"
option={{
xAxis: {
type: 'category',
data: TIERS.map((tier) => 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',
})),
}}
/>
);
}
@@ -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 (
<Flex direction="column" gap="4">
<Heading align="center">Players by tier & class</Heading>
<Box height="25rem">
<ThemedBar
data={TIERS.map((tier) => {
const players: Record<TankClass, number> = {
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<string, number | string> = {
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}
/>
</Box>
</Flex>
);
}
@@ -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 (
<Flex justify="center">
<Flex direction="column" maxWidth="45rem" flexGrow="1" py="6" gap="9">
<BattleDistributionAcrossTiers />
<BattleDistributionAcrossTiersByClass />
</Flex>
</Flex>
);
@@ -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() {
>
<Select.Trigger />
<Select.Content>
<Select.Item value={PlayerCountPeriod.Past120Days}>
Past 120 days
</Select.Item>
<Select.Item value={PlayerCountPeriod.Past90Days}>
Past 90 days
</Select.Item>
<Select.Item value={PlayerCountPeriod.Past60Days}>
Past 60 days
</Select.Item>
<Select.Item value={PlayerCountPeriod.ThisMonth}>
This month
</Select.Item>
<Select.Item value={PlayerCountPeriod.ThisWeek}>
This week
</Select.Item>
<Select.Item value={PlayerCountPeriod.Yesterday}>
Yesterday
</Select.Item>
<Select.Item value="total">Total</Select.Item>
<Select.Item value="d_120">Past 120 days</Select.Item>
<Select.Item value="d_90">Past 90 days</Select.Item>
<Select.Item value="d_60">Past 60 days</Select.Item>
<Select.Item value="d_30">This month</Select.Item>
<Select.Item value="d_7">This week</Select.Item>
<Select.Item value="d_1">Yesterday</Select.Item>
</Select.Content>
</Select.Root>
</Flex>
+36 -7
View File
@@ -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;
@@ -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<TankPerformanceEphemeral>()(() => ({
playerCountPeriod: PlayerCountPeriod.ThisMonth,
playerCountPeriod: 'd_30',
mode: TankPerformanceMode.Charts,
})),
);