mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-21 02:37:05 +02:00
feat: nivo graphs
This commit is contained in:
1 parent
f2da3994e4
commit
5e6f0e5d18
13 files changed
+157
-364
No files matched your search
@@ -20,5 +20,8 @@ export default defineConfig({
|
||||
'../../node_modules/.prisma/client/index-browser.js',
|
||||
},
|
||||
},
|
||||
ssr: {
|
||||
noExternal: [/^d3.*$/, /^@nivo.*$/],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
-57
@@ -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',
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
-65
@@ -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',
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+87
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
);
|
||||
Reference in new issue
Block a user