feat: add FPS range, device donut, and trust bar chart components
This commit is contained in:
@@ -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} />
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user