From 64cf13636ce784dec2e6750ee92afd5f2967ab7f Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sun, 26 Apr 2026 15:47:13 +0800 Subject: [PATCH] feat: add FPS range, device donut, and trust bar chart components --- components/charts/DeviceDonut.tsx | 75 ++++++++++++++++++++ components/charts/FpsRangeChart.tsx | 75 ++++++++++++++++++++ components/charts/TrustBar.tsx | 102 ++++++++++++++++++++++++++++ 3 files changed, 252 insertions(+) create mode 100644 components/charts/DeviceDonut.tsx create mode 100644 components/charts/FpsRangeChart.tsx create mode 100644 components/charts/TrustBar.tsx diff --git a/components/charts/DeviceDonut.tsx b/components/charts/DeviceDonut.tsx new file mode 100644 index 0000000..9c60840 --- /dev/null +++ b/components/charts/DeviceDonut.tsx @@ -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(() => { + 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 +} diff --git a/components/charts/FpsRangeChart.tsx b/components/charts/FpsRangeChart.tsx new file mode 100644 index 0000000..f3694e6 --- /dev/null +++ b/components/charts/FpsRangeChart.tsx @@ -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(() => { + 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 +} diff --git a/components/charts/TrustBar.tsx b/components/charts/TrustBar.tsx new file mode 100644 index 0000000..cb588df --- /dev/null +++ b/components/charts/TrustBar.tsx @@ -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(() => { + 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 ( + + ) +}