From 20755fd0fec2df8fc180daab6f81a2f90235b94d Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sun, 26 Apr 2026 15:45:26 +0800 Subject: [PATCH] feat: add historical, upscaler, and boxplot chart components --- components/charts/FpsBoxplot.tsx | 77 ++++++++++++++++++++ components/charts/HistoricalAreaChart.tsx | 78 ++++++++++++++++++++ components/charts/UpscalerBarChart.tsx | 88 +++++++++++++++++++++++ 3 files changed, 243 insertions(+) create mode 100644 components/charts/FpsBoxplot.tsx create mode 100644 components/charts/HistoricalAreaChart.tsx create mode 100644 components/charts/UpscalerBarChart.tsx diff --git a/components/charts/FpsBoxplot.tsx b/components/charts/FpsBoxplot.tsx new file mode 100644 index 0000000..580f67c --- /dev/null +++ b/components/charts/FpsBoxplot.tsx @@ -0,0 +1,77 @@ +"use client" + +import { useMemo } from "react" +import { EChartWrapper, CHART_THEME, getDeviceColor } from "./EChartWrapper" +import type { EChartsOption } from "echarts" + +interface BoxplotEntry { + hardwareSlug: string + hardwareName: string + min: number + q1: number + median: number + q3: number + max: number +} + +export function FpsBoxplot({ + data, + className, +}: { + data: BoxplotEntry[] + className?: string +}) { + const option = useMemo(() => { + const categories = data.map((d) => d.hardwareName) + const boxData = data.map((d) => [d.min, d.q1, d.median, d.q3, d.max]) + + return { + tooltip: { + trigger: "item", + backgroundColor: "#1a1025", + borderColor: CHART_THEME.border, + textStyle: { color: CHART_THEME.text, fontSize: 12 }, + formatter: (params) => { + const d = (params as { data: number[] }).data + if (!Array.isArray(d)) return "" + return `Min: ${d[0]}
Q1: ${d[1]}
Median: ${d[2]}
Q3: ${d[3]}
Max: ${d[4]}` + }, + }, + grid: { top: 16, right: 16, bottom: 24, left: 40 }, + xAxis: { + type: "category", + data: categories, + axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 }, + axisLine: { lineStyle: { color: CHART_THEME.border } }, + }, + yAxis: { + type: "value", + name: "FPS", + nameTextStyle: { color: CHART_THEME.textMuted, fontSize: 10 }, + axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 }, + splitLine: { lineStyle: { color: CHART_THEME.border, opacity: 0.3 } }, + }, + series: [ + { + type: "boxplot", + data: boxData, + itemStyle: { + color: CHART_THEME.primary + "20", + borderColor: CHART_THEME.primary, + borderWidth: 2, + }, + emphasis: { + itemStyle: { + borderColor: CHART_THEME.accent, + borderWidth: 3, + }, + }, + }, + ], + } + }, [data]) + + if (data.length === 0) return null + + return +} diff --git a/components/charts/HistoricalAreaChart.tsx b/components/charts/HistoricalAreaChart.tsx new file mode 100644 index 0000000..c0268c2 --- /dev/null +++ b/components/charts/HistoricalAreaChart.tsx @@ -0,0 +1,78 @@ +"use client" + +import { useMemo } from "react" +import { EChartWrapper, CHART_THEME, getDeviceColor } from "./EChartWrapper" +import type { EChartsOption } from "echarts" + +interface HistoricalEntry { + period: string + entries: Array<{ + hardwareSlug: string + avgFps: number + count: number + }> +} + +export function HistoricalAreaChart({ + data, + className, +}: { + data: HistoricalEntry[] + className?: string +}) { + const option = useMemo(() => { + const periods = data.map((d) => d.period) + const deviceSlugs = [ + ...new Set(data.flatMap((d) => d.entries.map((e) => e.hardwareSlug))), + ] + + const series = deviceSlugs.map((slug, idx) => ({ + name: slug.replace(/-/g, " "), + type: "line" as const, + stack: "total", + areaStyle: { opacity: 0.3 }, + emphasis: { focus: "series" as const }, + smooth: true, + data: periods.map((period) => { + const entry = data + .find((d) => d.period === period) + ?.entries.find((e) => e.hardwareSlug === slug) + return entry?.avgFps ?? null + }), + itemStyle: { color: getDeviceColor(idx) }, + lineStyle: { color: getDeviceColor(idx) }, + })) + + return { + tooltip: { + trigger: "axis", + backgroundColor: "#1a1025", + borderColor: CHART_THEME.border, + textStyle: { color: CHART_THEME.text, fontSize: 12 }, + }, + legend: { + top: 0, + textStyle: { color: CHART_THEME.textMuted, fontSize: 11 }, + }, + grid: { top: 30, right: 16, bottom: 24, left: 40 }, + xAxis: { + type: "category", + data: periods, + axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 }, + axisLine: { lineStyle: { color: CHART_THEME.border } }, + }, + yAxis: { + type: "value", + name: "AVG FPS", + nameTextStyle: { color: CHART_THEME.textMuted, fontSize: 10 }, + axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 }, + splitLine: { lineStyle: { color: CHART_THEME.border, opacity: 0.3 } }, + }, + series, + } + }, [data]) + + if (data.length === 0) return null + + return +} diff --git a/components/charts/UpscalerBarChart.tsx b/components/charts/UpscalerBarChart.tsx new file mode 100644 index 0000000..f98f186 --- /dev/null +++ b/components/charts/UpscalerBarChart.tsx @@ -0,0 +1,88 @@ +"use client" + +import { useMemo } from "react" +import { EChartWrapper, CHART_THEME, getDeviceColor } from "./EChartWrapper" +import type { EChartsOption } from "echarts" + +interface UpscalerStat { + fsrVersion: string + frameGenMethod: string + hardwareSlug: string + avgFps: number + count: number +} + +function formatCombo(fsr: string, fg: string): string { + const parts: string[] = [] + if (fsr !== "none") parts.push(fsr.toUpperCase()) + if (fg !== "none") parts.push(fg === "fsr_fg" ? "FSR FG" : "DLSS FG") + return parts.length > 0 ? parts.join(" + ") : "Native" +} + +export function UpscalerBarChart({ + data, + className, +}: { + data: UpscalerStat[] + className?: string +}) { + const option = useMemo(() => { + const combos = [ + ...new Set( + data.map((d) => formatCombo(d.fsrVersion, d.frameGenMethod)), + ), + ] + const deviceSlugs = [...new Set(data.map((d) => d.hardwareSlug))] + + const series = deviceSlugs.map((slug, idx) => ({ + name: slug.replace(/-/g, " "), + type: "bar" as const, + data: combos.map((combo) => { + const match = data.find( + (d) => + formatCombo(d.fsrVersion, d.frameGenMethod) === combo && + d.hardwareSlug === slug, + ) + return match?.avgFps ?? 0 + }), + itemStyle: { color: getDeviceColor(idx), borderRadius: [4, 4, 0, 0] }, + barGap: "10%", + })) + + return { + tooltip: { + trigger: "axis", + backgroundColor: "#1a1025", + borderColor: CHART_THEME.border, + textStyle: { color: CHART_THEME.text, fontSize: 12 }, + }, + legend: { + top: 0, + textStyle: { color: CHART_THEME.textMuted, fontSize: 11 }, + }, + grid: { top: 30, right: 16, bottom: 50, left: 40 }, + xAxis: { + type: "category", + data: combos, + axisLabel: { + color: CHART_THEME.textMuted, + fontSize: 10, + rotate: 30, + }, + axisLine: { lineStyle: { color: CHART_THEME.border } }, + }, + yAxis: { + type: "value", + name: "AVG FPS", + nameTextStyle: { color: CHART_THEME.textMuted, fontSize: 10 }, + axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 }, + splitLine: { lineStyle: { color: CHART_THEME.border, opacity: 0.3 } }, + }, + series, + } + }, [data]) + + if (data.length === 0) return null + + return +}