From 7e2ab978e1c06698961bd913709a84ab6a9cc33b Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Tue, 28 Apr 2026 23:08:01 +0800 Subject: [PATCH] feat: add performance tier and stability scatter charts --- app/game/[id]/game-page-client.tsx | 43 +++++++++++ components/charts/PerformanceTierChart.tsx | 72 +++++++++++++++++++ components/charts/StabilityScatterChart.tsx | 80 +++++++++++++++++++++ lib/api/game-stats.ts | 31 +++++++- 4 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 components/charts/PerformanceTierChart.tsx create mode 100644 components/charts/StabilityScatterChart.tsx diff --git a/app/game/[id]/game-page-client.tsx b/app/game/[id]/game-page-client.tsx index 1f08d4d..ef2dd1c 100644 --- a/app/game/[id]/game-page-client.tsx +++ b/app/game/[id]/game-page-client.tsx @@ -33,6 +33,8 @@ import { UpscalerBarChart } from "@/components/charts/UpscalerBarChart" import { FpsBoxplot } from "@/components/charts/FpsBoxplot" import { FpsRangeChart } from "@/components/charts/FpsRangeChart" import { DeviceDonut } from "@/components/charts/DeviceDonut" +import { PerformanceTierChart } from "@/components/charts/PerformanceTierChart" +import { StabilityScatterChart } from "@/components/charts/StabilityScatterChart" // Comments import { CommentSection } from "@/components/comments/comment-section" @@ -149,6 +151,20 @@ interface StatsResponse { hardwareName: string count: number }> + performanceTiers: Array<{ + hardwareSlug: string + unplayable: number + playable: number + smooth: number + excellent: number + }> + stabilityScatter: Array<{ + id: string + hardwareSlug: string + fpsAvg: number + fpsOnePercentLow: number + stabilityRatio: number + }> filterOptions: { protonVersions: string[] osVersions: string[] @@ -352,6 +368,8 @@ export function GamePageClient({ upscalerStats: filterUpscaler(stats.upscalerStats), fpsRange: filterByDevice(stats.fpsRange), deviceBreakdown: filterByDevice(stats.deviceBreakdown), + performanceTiers: filterByDevice(stats.performanceTiers), + stabilityScatter: filterByDevice(stats.stabilityScatter), } }, [stats, selectedDevices, filters]) @@ -1030,6 +1048,31 @@ export function GamePageClient({ )} + {/* Row 4 — Performance Tiers + Stability Scatter */} + {filteredStats && ( +
+ {filteredStats.performanceTiers.length > 0 && ( +
+

+ Performance Tiers +

+ +
+ )} + {filteredStats.stabilityScatter.length > 0 && ( +
+

+ Avg FPS vs 1% Low (Stability) +

+ [d.hardwareSlug, d.hardwareName]))} + /> +
+ )} +
+ )} + diff --git a/components/charts/PerformanceTierChart.tsx b/components/charts/PerformanceTierChart.tsx new file mode 100644 index 0000000..91600d6 --- /dev/null +++ b/components/charts/PerformanceTierChart.tsx @@ -0,0 +1,72 @@ +"use client" + +import { EChartWrapper } from "./EChartWrapper" + +interface TierData { + hardwareSlug: string + hardwareName?: string + unplayable: number + playable: number + smooth: number + excellent: number +} + +export function PerformanceTierChart({ data }: { data: TierData[] }) { + if (!data || data.length === 0) { + return
No tier data
+ } + + const labels = data.map((d) => d.hardwareName || d.hardwareSlug) + + const option = { + tooltip: { + trigger: "axis" as const, + axisPointer: { type: "shadow" as const }, + }, + legend: { + data: ["<30 fps", "30-59", "60-119", "≥120"], + textStyle: { color: "#999" }, + top: 0, + }, + grid: { left: 100, right: 20, top: 40, bottom: 30 }, + xAxis: { type: "value" as const, splitLine: { lineStyle: { color: "#333" } } }, + yAxis: { + type: "category" as const, + data: labels, + axisLine: { lineStyle: { color: "#555" } }, + axisLabel: { color: "#999" }, + }, + series: [ + { + name: "<30 fps", + type: "bar" as const, + stack: "total", + data: data.map((d) => d.unplayable), + itemStyle: { color: "#ef4444" }, + }, + { + name: "30-59", + type: "bar" as const, + stack: "total", + data: data.map((d) => d.playable), + itemStyle: { color: "#eab308" }, + }, + { + name: "60-119", + type: "bar" as const, + stack: "total", + data: data.map((d) => d.smooth), + itemStyle: { color: "#22c55e" }, + }, + { + name: "≥120", + type: "bar" as const, + stack: "total", + data: data.map((d) => d.excellent), + itemStyle: { color: "#3b82f6" }, + }, + ], + } + + return +} diff --git a/components/charts/StabilityScatterChart.tsx b/components/charts/StabilityScatterChart.tsx new file mode 100644 index 0000000..6cbed23 --- /dev/null +++ b/components/charts/StabilityScatterChart.tsx @@ -0,0 +1,80 @@ +"use client" + +import { EChartWrapper } from "./EChartWrapper" + +interface ScatterPoint { + id: string + hardwareSlug: string + fpsAvg: number + fpsOnePercentLow: number + stabilityRatio: number +} + +export function StabilityScatterChart({ data, deviceNames }: { data: ScatterPoint[]; deviceNames?: Record }) { + if (!data || data.length === 0) { + return
No stability data yet
+ } + + // Group by device + const deviceGroups = new Map() + for (const point of data) { + const existing = deviceGroups.get(point.hardwareSlug) || [] + existing.push(point) + deviceGroups.set(point.hardwareSlug, existing) + } + + const colors = ["#3b82f6", "#22c55e", "#f59e0b", "#ef4444", "#8b5cf6"] + const series = Array.from(deviceGroups.entries()).map(([slug, points], idx) => ({ + name: deviceNames?.[slug] || slug, + type: "scatter" as const, + data: points.map((p) => [p.fpsAvg, p.fpsOnePercentLow]), + itemStyle: { color: colors[idx % colors.length] }, + symbolSize: 8, + })) + + // Perfect stability line (y = x) + const maxFps = Math.max(...data.map((d) => d.fpsAvg)) + const perfectLine = { + name: "Perfect Stability", + type: "line" as const, + data: [ + [0, 0], + [maxFps, maxFps], + ], + lineStyle: { color: "#555", type: "dashed" as const }, + symbol: "none", + silent: true, + } + + const option = { + tooltip: { + trigger: "item" as const, + formatter: (params: any) => { + if (params.seriesName === "Perfect Stability") return "" + return `${params.seriesName}
Avg: ${params.value[0]} fps
1% Low: ${params.value[1]} fps` + }, + }, + legend: { + textStyle: { color: "#999" }, + top: 0, + }, + grid: { left: 60, right: 20, top: 40, bottom: 40 }, + xAxis: { + type: "value" as const, + name: "Avg FPS", + nameTextStyle: { color: "#999" }, + splitLine: { lineStyle: { color: "#333" } }, + axisLabel: { color: "#999" }, + }, + yAxis: { + type: "value" as const, + name: "1% Low FPS", + nameTextStyle: { color: "#999" }, + splitLine: { lineStyle: { color: "#333" } }, + axisLabel: { color: "#999" }, + }, + series: [...series, perfectLine], + } + + return +} diff --git a/lib/api/game-stats.ts b/lib/api/game-stats.ts index 3c6a0c6..8531530 100644 --- a/lib/api/game-stats.ts +++ b/lib/api/game-stats.ts @@ -252,7 +252,34 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get( }), ) - // ── 9. Filter options ──────────────────────────────────────── + // ── 9. Performance tier breakdown per device ──────────────── + const tierMap = new Map() + for (const e of entries) { + const existing = tierMap.get(e.hardwareSlug) || { unplayable: 0, playable: 0, smooth: 0, excellent: 0 } + if (e.fpsAvg < 30) existing.unplayable++ + else if (e.fpsAvg < 60) existing.playable++ + else if (e.fpsAvg < 120) existing.smooth++ + else existing.excellent++ + tierMap.set(e.hardwareSlug, existing) + } + + const performanceTiers = Array.from(tierMap.entries()).map(([slug, tiers]) => ({ + hardwareSlug: slug, + ...tiers, + })) + + // ── 10. Stability scatter data ────────────────────────────── + const stabilityScatter = entries + .filter((e) => e.fpsOnePercentLow != null) + .map((e) => ({ + id: e.id, + hardwareSlug: e.hardwareSlug, + fpsAvg: e.fpsAvg ?? 0, + fpsOnePercentLow: e.fpsOnePercentLow!, + stabilityRatio: e.fpsAvg > 0 ? Math.min(1, e.fpsOnePercentLow! / e.fpsAvg) : 0, + })) + + // ── 11. Filter options ──────────────────────────────────────── const protonVersions = [ ...new Set(entries.map((e) => e.protonVersion).filter(Boolean)), ] as string[] @@ -277,6 +304,8 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get( upscalerStats, fpsRange, deviceBreakdown, + performanceTiers, + stabilityScatter, filterOptions: { protonVersions, osVersions }, } },