feat: add performance tier and stability scatter charts
This commit is contained in:
@@ -33,6 +33,8 @@ import { UpscalerBarChart } from "@/components/charts/UpscalerBarChart"
|
|||||||
import { FpsBoxplot } from "@/components/charts/FpsBoxplot"
|
import { FpsBoxplot } from "@/components/charts/FpsBoxplot"
|
||||||
import { FpsRangeChart } from "@/components/charts/FpsRangeChart"
|
import { FpsRangeChart } from "@/components/charts/FpsRangeChart"
|
||||||
import { DeviceDonut } from "@/components/charts/DeviceDonut"
|
import { DeviceDonut } from "@/components/charts/DeviceDonut"
|
||||||
|
import { PerformanceTierChart } from "@/components/charts/PerformanceTierChart"
|
||||||
|
import { StabilityScatterChart } from "@/components/charts/StabilityScatterChart"
|
||||||
|
|
||||||
// Comments
|
// Comments
|
||||||
import { CommentSection } from "@/components/comments/comment-section"
|
import { CommentSection } from "@/components/comments/comment-section"
|
||||||
@@ -149,6 +151,20 @@ interface StatsResponse {
|
|||||||
hardwareName: string
|
hardwareName: string
|
||||||
count: number
|
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: {
|
filterOptions: {
|
||||||
protonVersions: string[]
|
protonVersions: string[]
|
||||||
osVersions: string[]
|
osVersions: string[]
|
||||||
@@ -352,6 +368,8 @@ export function GamePageClient({
|
|||||||
upscalerStats: filterUpscaler(stats.upscalerStats),
|
upscalerStats: filterUpscaler(stats.upscalerStats),
|
||||||
fpsRange: filterByDevice(stats.fpsRange),
|
fpsRange: filterByDevice(stats.fpsRange),
|
||||||
deviceBreakdown: filterByDevice(stats.deviceBreakdown),
|
deviceBreakdown: filterByDevice(stats.deviceBreakdown),
|
||||||
|
performanceTiers: filterByDevice(stats.performanceTiers),
|
||||||
|
stabilityScatter: filterByDevice(stats.stabilityScatter),
|
||||||
}
|
}
|
||||||
}, [stats, selectedDevices, filters])
|
}, [stats, selectedDevices, filters])
|
||||||
|
|
||||||
@@ -1030,6 +1048,31 @@ export function GamePageClient({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Row 4 — Performance Tiers + Stability Scatter */}
|
||||||
|
{filteredStats && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{filteredStats.performanceTiers.length > 0 && (
|
||||||
|
<div className="rounded-xl border border-border bg-text/3 p-4">
|
||||||
|
<h3 className="text-sm font-medium text-text/80 mb-2">
|
||||||
|
Performance Tiers
|
||||||
|
</h3>
|
||||||
|
<PerformanceTierChart data={filteredStats.performanceTiers} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filteredStats.stabilityScatter.length > 0 && (
|
||||||
|
<div className="rounded-xl border border-border bg-text/3 p-4">
|
||||||
|
<h3 className="text-sm font-medium text-text/80 mb-2">
|
||||||
|
Avg FPS vs 1% Low (Stability)
|
||||||
|
</h3>
|
||||||
|
<StabilityScatterChart
|
||||||
|
data={filteredStats.stabilityScatter}
|
||||||
|
deviceNames={Object.fromEntries(filteredStats.deviceBreakdown.map((d) => [d.hardwareSlug, d.hardwareName]))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
|||||||
@@ -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 <div className="flex items-center justify-center h-48 text-sm text-text/40">No tier data</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <EChartWrapper option={option} height={250} />
|
||||||
|
}
|
||||||
@@ -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<string, string> }) {
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return <div className="flex items-center justify-center h-48 text-sm text-text/40">No stability data yet</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by device
|
||||||
|
const deviceGroups = new Map<string, ScatterPoint[]>()
|
||||||
|
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}<br/>Avg: ${params.value[0]} fps<br/>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 <EChartWrapper option={option} height={300} />
|
||||||
|
}
|
||||||
+30
-1
@@ -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<string, { unplayable: number; playable: number; smooth: number; excellent: number }>()
|
||||||
|
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 = [
|
const protonVersions = [
|
||||||
...new Set(entries.map((e) => e.protonVersion).filter(Boolean)),
|
...new Set(entries.map((e) => e.protonVersion).filter(Boolean)),
|
||||||
] as string[]
|
] as string[]
|
||||||
@@ -277,6 +304,8 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get(
|
|||||||
upscalerStats,
|
upscalerStats,
|
||||||
fpsRange,
|
fpsRange,
|
||||||
deviceBreakdown,
|
deviceBreakdown,
|
||||||
|
performanceTiers,
|
||||||
|
stabilityScatter,
|
||||||
filterOptions: { protonVersions, osVersions },
|
filterOptions: { protonVersions, osVersions },
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user