refactor: convert to bun workspaces monorepo
- Move web app into apps/web/ - Create packages/shared/ with shared types - Create plugins/decky-vault/ scaffold - Root package.json manages workspaces only
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
"use client"
|
||||
|
||||
import { EChartWrapper, getDeviceColor } from "./EChartWrapper"
|
||||
|
||||
interface BatteryLifePoint {
|
||||
id: string
|
||||
hardwareSlug: string
|
||||
tdpWatts: number
|
||||
estimatedBatteryHours: number
|
||||
wattHours: number | null
|
||||
tdpMax: number | null
|
||||
estimatedAtMaxTdpMin: number | null
|
||||
}
|
||||
|
||||
interface BatteryLifeChartProps {
|
||||
data: BatteryLifePoint[]
|
||||
deviceNames?: Record<string, string>
|
||||
}
|
||||
|
||||
export function BatteryLifeChart({ data, deviceNames }: BatteryLifeChartProps) {
|
||||
if (!data || data.length === 0) {
|
||||
return <div className="flex items-center justify-center h-48 text-sm text-text/40">No battery data available</div>
|
||||
}
|
||||
|
||||
// Group by device for separate series
|
||||
const deviceGroups = new Map<string, BatteryLifePoint[]>()
|
||||
for (const point of data) {
|
||||
const existing = deviceGroups.get(point.hardwareSlug) || []
|
||||
existing.push(point)
|
||||
deviceGroups.set(point.hardwareSlug, existing)
|
||||
}
|
||||
|
||||
// Build trend lines: for each device, compute wattHours / tdp = hours for a range of TDPs
|
||||
const series: Array<Record<string, unknown>> = []
|
||||
let seriesIdx = 0
|
||||
|
||||
for (const [slug, points] of deviceGroups.entries()) {
|
||||
const color = getDeviceColor(seriesIdx)
|
||||
const name = deviceNames?.[slug] || slug
|
||||
|
||||
// Scatter points: TDP vs battery hours
|
||||
series.push({
|
||||
name,
|
||||
type: "scatter" as const,
|
||||
data: points.map((p) => [p.tdpWatts, p.estimatedBatteryHours]),
|
||||
itemStyle: { color },
|
||||
symbolSize: 10,
|
||||
})
|
||||
|
||||
// Trend line: compute theoretical curve using average wattHours for this device
|
||||
const avgWh = points.reduce((sum, p) => sum + (p.wattHours ?? 0), 0) / points.length
|
||||
if (avgWh > 0) {
|
||||
const tdpRange = [2, 5, 8, 10, 12, 15, 18, 20, 25, 30].filter(
|
||||
(tdp) => tdp <= (points[0].tdpMax ?? 30),
|
||||
)
|
||||
series.push({
|
||||
name: `${name} (est.)`,
|
||||
type: "line" as const,
|
||||
data: tdpRange.map((tdp) => [tdp, Math.round((avgWh / tdp) * 10) / 10]),
|
||||
lineStyle: { color, type: "dashed" as const, width: 1 },
|
||||
symbol: "none",
|
||||
silent: true,
|
||||
})
|
||||
}
|
||||
|
||||
seriesIdx++
|
||||
}
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: "item" as const,
|
||||
formatter: (params: unknown) => {
|
||||
const p = params as { seriesName?: string; value?: [number, number] }
|
||||
if (!p.seriesName || p.seriesName.includes("(est.)")) return ""
|
||||
return `${p.seriesName}<br/>TDP: ${p.value?.[0]}W<br/>Battery: ~${p.value?.[1]}h`
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
textStyle: { color: "#999" },
|
||||
top: 0,
|
||||
},
|
||||
grid: { left: 60, right: 20, top: 40, bottom: 40 },
|
||||
xAxis: {
|
||||
type: "value" as const,
|
||||
name: "TDP (W)",
|
||||
nameTextStyle: { color: "#999" },
|
||||
splitLine: { lineStyle: { color: "#333" } },
|
||||
axisLabel: { color: "#999" },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value" as const,
|
||||
name: "Battery (h)",
|
||||
nameTextStyle: { color: "#999" },
|
||||
splitLine: { lineStyle: { color: "#333" } },
|
||||
axisLabel: { color: "#999" },
|
||||
},
|
||||
series,
|
||||
}
|
||||
|
||||
return <EChartWrapper option={option} height={300} />
|
||||
}
|
||||
@@ -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,67 @@
|
||||
"use client"
|
||||
|
||||
import { useRef, useCallback } from "react"
|
||||
import ReactECharts from "echarts-for-react"
|
||||
import type { EChartsOption } from "echarts"
|
||||
|
||||
// Project theme colors matching globals.css
|
||||
export const CHART_THEME = {
|
||||
bg: "transparent",
|
||||
text: "#ebe4f1",
|
||||
textMuted: "#6b5a7d",
|
||||
textSubtle: "#4a3a5c",
|
||||
border: "#3d2d52",
|
||||
primary: "#eb3779",
|
||||
secondary: "#571b8b",
|
||||
accent: "#fb793c",
|
||||
success: "#22c55e",
|
||||
info: "#3b82f6",
|
||||
warning: "#f59e0b",
|
||||
// Device-specific colors
|
||||
deviceColors: [
|
||||
"#eb3779", // primary (OLED)
|
||||
"#571b8b", // secondary (LCD)
|
||||
"#fb793c", // accent (Steam Machine)
|
||||
"#22c55e",
|
||||
"#3b82f6",
|
||||
"#f59e0b",
|
||||
"#a78bfa",
|
||||
"#ec4899",
|
||||
],
|
||||
}
|
||||
|
||||
export function getDeviceColor(index: number): string {
|
||||
return CHART_THEME.deviceColors[index % CHART_THEME.deviceColors.length]
|
||||
}
|
||||
|
||||
export function EChartWrapper({
|
||||
option,
|
||||
height = 300,
|
||||
className = "",
|
||||
}: {
|
||||
option: EChartsOption
|
||||
height?: number
|
||||
className?: string
|
||||
}) {
|
||||
const chartRef = useRef<ReactECharts>(null)
|
||||
|
||||
const onEvents = useCallback(
|
||||
() => ({
|
||||
// Placeholder for future event handlers
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={className} style={{ height }}>
|
||||
<ReactECharts
|
||||
ref={chartRef}
|
||||
option={option}
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
opts={{ renderer: "canvas" }}
|
||||
onEvents={onEvents()}
|
||||
theme={undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { EChartWrapper, CHART_THEME } from "./EChartWrapper"
|
||||
import type { EChartsOption } from "echarts"
|
||||
|
||||
interface BoxplotEntry {
|
||||
hardwareSlug: string
|
||||
hardwareName: string
|
||||
min: number
|
||||
q1: number
|
||||
median: number
|
||||
q3: number
|
||||
max: number
|
||||
}
|
||||
|
||||
export function FpsBoxplot({
|
||||
data,
|
||||
className,
|
||||
}: {
|
||||
data: BoxplotEntry[]
|
||||
className?: string
|
||||
}) {
|
||||
const option = useMemo<EChartsOption>(() => {
|
||||
const categories = data.map((d) => d.hardwareName)
|
||||
const boxData = data.map((d) => [d.min, d.q1, d.median, d.q3, d.max])
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
backgroundColor: "#1a1025",
|
||||
borderColor: CHART_THEME.border,
|
||||
textStyle: { color: CHART_THEME.text, fontSize: 12 },
|
||||
formatter: (params) => {
|
||||
const d = (params as { data: number[] }).data
|
||||
if (!Array.isArray(d)) return ""
|
||||
return `Min: ${d[0]}<br/>Q1: ${d[1]}<br/>Median: ${d[2]}<br/>Q3: ${d[3]}<br/>Max: ${d[4]}`
|
||||
},
|
||||
},
|
||||
grid: { top: 16, right: 16, bottom: 24, left: 40 },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: categories,
|
||||
axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
|
||||
axisLine: { lineStyle: { color: CHART_THEME.border } },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
name: "FPS",
|
||||
nameTextStyle: { color: CHART_THEME.textMuted, fontSize: 10 },
|
||||
axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
|
||||
splitLine: { lineStyle: { color: CHART_THEME.border, opacity: 0.3 } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: "boxplot",
|
||||
data: boxData,
|
||||
itemStyle: {
|
||||
color: CHART_THEME.primary + "20",
|
||||
borderColor: CHART_THEME.primary,
|
||||
borderWidth: 2,
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
borderColor: CHART_THEME.accent,
|
||||
borderWidth: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}, [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,78 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { EChartWrapper, CHART_THEME, getDeviceColor } from "./EChartWrapper"
|
||||
import type { EChartsOption } from "echarts"
|
||||
|
||||
interface HistoricalEntry {
|
||||
period: string
|
||||
entries: Array<{
|
||||
hardwareSlug: string
|
||||
avgFps: number
|
||||
count: number
|
||||
}>
|
||||
}
|
||||
|
||||
export function HistoricalAreaChart({
|
||||
data,
|
||||
className,
|
||||
}: {
|
||||
data: HistoricalEntry[]
|
||||
className?: string
|
||||
}) {
|
||||
const option = useMemo<EChartsOption>(() => {
|
||||
const periods = data.map((d) => d.period)
|
||||
const deviceSlugs = [
|
||||
...new Set(data.flatMap((d) => d.entries.map((e) => e.hardwareSlug))),
|
||||
]
|
||||
|
||||
const series = deviceSlugs.map((slug, idx) => ({
|
||||
name: slug.replace(/-/g, " "),
|
||||
type: "line" as const,
|
||||
stack: "total",
|
||||
areaStyle: { opacity: 0.3 },
|
||||
emphasis: { focus: "series" as const },
|
||||
smooth: true,
|
||||
data: periods.map((period) => {
|
||||
const entry = data
|
||||
.find((d) => d.period === period)
|
||||
?.entries.find((e) => e.hardwareSlug === slug)
|
||||
return entry?.avgFps ?? null
|
||||
}),
|
||||
itemStyle: { color: getDeviceColor(idx) },
|
||||
lineStyle: { color: getDeviceColor(idx) },
|
||||
}))
|
||||
|
||||
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: periods,
|
||||
axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
|
||||
axisLine: { lineStyle: { color: CHART_THEME.border } },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
name: "AVG FPS",
|
||||
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={280} className={className} />
|
||||
}
|
||||
@@ -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,81 @@
|
||||
"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,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
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} />
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { EChartWrapper, CHART_THEME, getDeviceColor } from "./EChartWrapper"
|
||||
import type { EChartsOption } from "echarts"
|
||||
|
||||
interface UpscalerStat {
|
||||
upscalerType: string
|
||||
upscalerVersion?: string | null
|
||||
frameGenMethod: string
|
||||
hardwareSlug: string
|
||||
avgFps: number
|
||||
count: number
|
||||
}
|
||||
|
||||
function formatCombo(upscalerType: string, upscalerVersion: string | null | undefined, fg: string): string {
|
||||
const parts: string[] = []
|
||||
if (upscalerType !== "none") {
|
||||
const upscalerLabel = upscalerVersion
|
||||
? `${upscalerType.toUpperCase()} ${upscalerVersion}`
|
||||
: upscalerType.toUpperCase()
|
||||
parts.push(upscalerLabel)
|
||||
}
|
||||
if (fg !== "none") {
|
||||
if (fg === "fsr_fg") parts.push("FSR FG")
|
||||
else if (fg === "dlss_fg") parts.push("DLSS FG")
|
||||
else if (fg === "lsfg") parts.push("LSFG")
|
||||
else parts.push(fg.toUpperCase())
|
||||
}
|
||||
return parts.length > 0 ? parts.join(" + ") : "Native"
|
||||
}
|
||||
|
||||
export function UpscalerBarChart({
|
||||
data,
|
||||
className,
|
||||
}: {
|
||||
data: UpscalerStat[]
|
||||
className?: string
|
||||
}) {
|
||||
const option = useMemo<EChartsOption>(() => {
|
||||
const combos = [
|
||||
...new Set(
|
||||
data.map((d) => formatCombo(d.upscalerType, d.upscalerVersion, d.frameGenMethod)),
|
||||
),
|
||||
]
|
||||
const deviceSlugs = [...new Set(data.map((d) => d.hardwareSlug))]
|
||||
|
||||
const series = deviceSlugs.map((slug, idx) => ({
|
||||
name: slug.replace(/-/g, " "),
|
||||
type: "bar" as const,
|
||||
data: combos.map((combo) => {
|
||||
const match = data.find(
|
||||
(d) =>
|
||||
formatCombo(d.upscalerType, d.upscalerVersion, d.frameGenMethod) === combo &&
|
||||
d.hardwareSlug === slug,
|
||||
)
|
||||
return match?.avgFps ?? 0
|
||||
}),
|
||||
itemStyle: { color: getDeviceColor(idx), borderRadius: [4, 4, 0, 0] },
|
||||
barGap: "10%",
|
||||
}))
|
||||
|
||||
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: 50, left: 40 },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: combos,
|
||||
axisLabel: {
|
||||
color: CHART_THEME.textMuted,
|
||||
fontSize: 10,
|
||||
rotate: 30,
|
||||
},
|
||||
axisLine: { lineStyle: { color: CHART_THEME.border } },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
name: "AVG FPS",
|
||||
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={280} className={className} />
|
||||
}
|
||||
Reference in New Issue
Block a user