feat: add FPS range, device donut, and trust bar chart components

This commit is contained in:
2026-04-26 15:47:13 +08:00
parent 20755fd0fe
commit 64cf13636c
3 changed files with 252 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
"use client"
import { useMemo } from "react"
import { EChartWrapper, CHART_THEME, getDeviceColor } from "./EChartWrapper"
import type { EChartsOption } from "echarts"
interface DeviceEntry {
hardwareSlug: string
hardwareName: string
count: number
}
export function DeviceDonut({
data,
className,
}: {
data: DeviceEntry[]
className?: string
}) {
const option = useMemo<EChartsOption>(() => {
const total = data.reduce((sum, d) => sum + d.count, 0)
return {
tooltip: {
trigger: "item",
backgroundColor: "#1a1025",
borderColor: CHART_THEME.border,
textStyle: { color: CHART_THEME.text, fontSize: 12 },
formatter: "{b}: {c} ({d}%)",
},
series: [
{
type: "pie",
radius: ["50%", "75%"],
center: ["50%", "55%"],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 6,
borderColor: "#100b14",
borderWidth: 2,
},
label: {
show: true,
position: "center",
formatter: `{total|${total}}\n{label|entries}`,
rich: {
total: {
fontSize: 22,
fontWeight: "bold",
color: CHART_THEME.text,
lineHeight: 30,
},
label: {
fontSize: 11,
color: CHART_THEME.textMuted,
},
},
},
emphasis: {
label: { show: true },
},
data: data.map((d, idx) => ({
name: d.hardwareName,
value: d.count,
itemStyle: { color: getDeviceColor(idx) },
})),
},
],
}
}, [data])
if (data.length === 0) return null
return <EChartWrapper option={option} height={220} className={className} />
}
+75
View File
@@ -0,0 +1,75 @@
"use client"
import { useMemo } from "react"
import { EChartWrapper, CHART_THEME, getDeviceColor } from "./EChartWrapper"
import type { EChartsOption } from "echarts"
interface RangeEntry {
id: string
hardwareSlug: string
fpsLow: number
fpsAvg: number
fpsHigh: number
isRawPerformer: boolean
}
export function FpsRangeChart({
data,
className,
}: {
data: RangeEntry[]
className?: string
}) {
const option = useMemo<EChartsOption>(() => {
const devices = [...new Set(data.map((d) => d.hardwareSlug))]
const sorted = [...data].sort((a, b) => b.fpsAvg - a.fpsAvg)
const labels = sorted.map((_, i) => `#${i + 1}`)
const series = devices.map((device, idx) => ({
name: device.replace(/-/g, " "),
type: "bar" as const,
stack: "range",
data: sorted.map((entry) => {
if (entry.hardwareSlug !== device) return 0
return entry.fpsHigh - entry.fpsLow
}),
itemStyle: {
color: getDeviceColor(idx),
borderRadius: [2, 2, 0, 0],
},
barWidth: "60%",
}))
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: labels,
axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
axisLine: { lineStyle: { color: CHART_THEME.border } },
},
yAxis: {
type: "value",
name: "FPS Range",
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 <EChartWrapper option={option} height={220} className={className} />
}
+102
View File
@@ -0,0 +1,102 @@
"use client"
import { useMemo } from "react"
import { EChartWrapper, CHART_THEME } from "./EChartWrapper"
import type { EChartsOption } from "echarts"
interface TrustEntry {
id: string
hardwareSlug: string
upvotes: number
downvotes: number
verifiedAt: string | null
userNotes: string | null
createdAt: string
}
export function TrustBar({
data,
className,
}: {
data: TrustEntry[]
className?: string
}) {
const option = useMemo<EChartsOption>(() => {
const sorted = [...data]
.sort((a, b) => b.upvotes - b.downvotes - (a.upvotes - a.downvotes))
.slice(0, 20)
const labels = sorted.map((e) => {
const date = new Date(e.createdAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
return e.userNotes
? `${e.userNotes.slice(0, 20)}...`
: `Entry ${date}`
})
const scores = sorted.map((e) => e.upvotes - e.downvotes)
return {
tooltip: {
trigger: "axis",
backgroundColor: "#1a1025",
borderColor: CHART_THEME.border,
textStyle: { color: CHART_THEME.text, fontSize: 12 },
formatter: (params) => {
const p = Array.isArray(params) ? params[0] : null
if (!p) return ""
const idx = (p as { dataIndex: number }).dataIndex
const entry = sorted[idx]
if (!entry) return ""
return `${entry.upvotes}${entry.downvotes}${entry.verifiedAt ? " ✅ Verified" : ""}`
},
},
grid: { top: 8, right: 16, bottom: 8, left: 120 },
xAxis: {
type: "value",
axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
splitLine: { lineStyle: { color: CHART_THEME.border, opacity: 0.3 } },
},
yAxis: {
type: "category",
data: labels,
inverse: true,
axisLabel: {
color: CHART_THEME.textMuted,
fontSize: 10,
width: 100,
overflow: "truncate",
},
axisLine: { show: false },
axisTick: { show: false },
},
series: [
{
type: "bar",
data: scores.map((score, idx) => ({
value: score,
itemStyle: {
color: sorted[idx].verifiedAt
? CHART_THEME.success
: CHART_THEME.textSubtle,
borderRadius: [0, 4, 4, 0],
},
})),
barWidth: "60%",
},
],
}
}, [data])
if (data.length === 0) return null
return (
<EChartWrapper
option={option}
height={Math.min(300, data.length * 28 + 16)}
className={className}
/>
)
}