206 lines
15 KiB
TypeScript
206 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { Bar, BarChart, CartesianGrid, LabelList, XAxis, YAxis } from "recharts";
|
|
import { CheckIcon, MinusIcon } from "lucide-react";
|
|
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
|
|
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
|
import { Field, FieldContent, FieldDescription, FieldLabel } from "@/components/ui/field";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { selectAnalytics, type PublicAnalytics, type RankedAnalyticsItem } from "@/lib/analytics/model";
|
|
import { rarityColor } from "@/lib/catalog/rarity";
|
|
|
|
const formatCount = (count: number) => count.toLocaleString("th-TH");
|
|
const chartConfig = { count: { label: "จำนวนครั้ง", color: "var(--muted-foreground)" } };
|
|
const rarityLabel = (rarity: number | null) => rarity ? `${rarity} ดาว` : "ไม่ระบุดาว";
|
|
|
|
function ItemImageTick({ x, y, item }: { x: number; y: number; item: RankedAnalyticsItem }) {
|
|
const [failedSource, setFailedSource] = useState<string | null>(null);
|
|
return <g transform={`translate(${x},${y})`}>
|
|
<title>{item.name} · {rarityLabel(item.rarity)}</title>
|
|
{item.imageUrl && failedSource !== item.imageUrl ?
|
|
<image href={item.imageUrl} x={-32} y={-16} width={32} height={32} preserveAspectRatio="xMidYMid meet"
|
|
onError={() => setFailedSource(item.imageUrl)} /> :
|
|
<text x={-16} y={0} textAnchor="middle" dominantBaseline="central" className="fill-foreground">{item.name.slice(0, 1)}</text>}
|
|
</g>;
|
|
}
|
|
|
|
function UsageRanking({ title, description, items }: {
|
|
title: string;
|
|
description: string;
|
|
items: RankedAnalyticsItem[];
|
|
}) {
|
|
const top = items.slice(0, 10).map((item) => ({ ...item, fill: rarityColor(item.rarity) }));
|
|
const rarities = [...new Set(top.map((item) => item.rarity))].sort((a, b) => (b ?? 0) - (a ?? 0));
|
|
return (
|
|
<Card className="min-w-0">
|
|
<CardHeader>
|
|
<CardTitle><h3>{title}</h3></CardTitle>
|
|
<CardDescription>{description}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-4">
|
|
{items.length ? <>
|
|
<ul aria-label="สีตามระดับดาว" className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
|
{rarities.map((rarity) => <li key={rarity ?? "unknown"} className="flex items-center gap-1.5">
|
|
<span aria-hidden="true" className="size-2.5 rounded-sm" style={{ backgroundColor: rarityColor(rarity) }} />
|
|
{rarityLabel(rarity)}
|
|
</li>)}
|
|
</ul>
|
|
<ChartContainer config={chartConfig} className="w-full aspect-auto" style={{ height: Math.max(150, top.length * 36 + 30) }} aria-label={`${title} 10 อันดับแรก`}>
|
|
<BarChart accessibilityLayer data={top} layout="vertical" margin={{ left: 0, right: 36, top: 4, bottom: 0 }}>
|
|
<CartesianGrid horizontal={false} />
|
|
<XAxis type="number" allowDecimals={false} axisLine={false} tickLine={false} tickFormatter={formatCount} />
|
|
<YAxis dataKey="key" type="category" width={46} axisLine={false} tickLine={false}
|
|
tick={({ x, y, payload }) => {
|
|
const item = top.find((entry) => entry.key === payload.value);
|
|
return item ? <ItemImageTick x={Number(x)} y={Number(y)} item={item} /> : <g />;
|
|
}} />
|
|
<ChartTooltip content={<ChartTooltipContent labelFormatter={(_label, payload) => {
|
|
const item = payload[0]?.payload;
|
|
return item ? `${item.name} · ${rarityLabel(item.rarity)}` : "";
|
|
}} />} />
|
|
<Bar dataKey="count" fill="var(--color-count)" radius={4} isAnimationActive={false}>
|
|
<LabelList dataKey="count" position="right" className="fill-foreground" formatter={(value) => formatCount(Number(value))} />
|
|
</Bar>
|
|
</BarChart>
|
|
</ChartContainer>
|
|
<details data-ranking className="group">
|
|
<summary className="cursor-pointer rounded-md py-2 text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
|
ดูอันดับทั้งหมด ({formatCount(items.length)} รายการ)
|
|
</summary>
|
|
<Table>
|
|
<TableHeader><TableRow>
|
|
<TableHead className="w-12">อันดับ</TableHead>
|
|
<TableHead>ชื่อ</TableHead>
|
|
<TableHead className="text-right">ครั้ง</TableHead>
|
|
</TableRow></TableHeader>
|
|
<TableBody>{items.map((item, index) => <TableRow key={item.key}>
|
|
<TableCell>{formatCount(index + 1)}</TableCell>
|
|
<TableCell className="whitespace-normal">
|
|
<div className="flex items-center gap-2">
|
|
<Avatar>
|
|
{item.imageUrl ? <AvatarImage src={item.imageUrl} alt="" loading="lazy" /> : null}
|
|
<AvatarFallback>{item.name.slice(0, 1)}</AvatarFallback>
|
|
</Avatar>
|
|
<span className="flex min-w-0 flex-col gap-0.5 break-words">
|
|
<span>{item.name}</span>
|
|
<span className="text-xs text-muted-foreground">{rarityLabel(item.rarity)}</span>
|
|
</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-right tabular-nums">{formatCount(item.count)}</TableCell>
|
|
</TableRow>)}</TableBody>
|
|
</Table>
|
|
</details>
|
|
</> : <Empty>
|
|
<EmptyHeader><EmptyTitle>ยังไม่มีข้อมูล</EmptyTitle><EmptyDescription>ยังไม่มีรายการในแหล่งข้อมูลที่เลือก</EmptyDescription></EmptyHeader>
|
|
</Empty>}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export function AnalyticsDashboard({ data }: { data: PublicAnalytics }) {
|
|
const [includeStygian, setIncludeStygian] = useState(true);
|
|
const [excludedGuideIds, setExcludedGuideIds] = useState<Set<string>>(() => new Set());
|
|
const [ignoreRepeatedTeams, setIgnoreRepeatedTeams] = useState(true);
|
|
const selected = selectAnalytics(data, includeStygian, excludedGuideIds, ignoreRepeatedTeams);
|
|
const toggleGuide = (id: string) => setExcludedGuideIds((previous) => {
|
|
const next = new Set(previous);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
const summaries = [
|
|
{ label: "ไกด์ตัวละคร", count: selected.guideCount },
|
|
{ label: "เวอร์ชัน Stygian", count: selected.stygianScheduleCount },
|
|
{ label: "ทีมทั้งหมด", count: selected.teamCount },
|
|
{ label: "ตัวละครที่ใช้ในทีม", count: selected.team.character.length },
|
|
{ label: "อาวุธที่ใช้ในทีม", count: selected.team.weapon.length },
|
|
{ label: "เซ็ตอาร์ติแฟกต์ที่ใช้ในทีม", count: selected.team.artifact.length },
|
|
];
|
|
|
|
return <div className="flex flex-col gap-8">
|
|
<Field orientation="horizontal" className="max-w-xl">
|
|
<Switch id="include-stygian" checked={includeStygian} onCheckedChange={setIncludeStygian} aria-labelledby="include-stygian-label" aria-describedby="stygian-description" />
|
|
<FieldContent>
|
|
<FieldLabel id="include-stygian-label" htmlFor="include-stygian">รวมข้อมูล Stygian</FieldLabel>
|
|
<FieldDescription id="stygian-description">รวมทุกเวอร์ชัน ระดับบอส และทีมที่แสดงในไกด์ Stygian</FieldDescription>
|
|
</FieldContent>
|
|
</Field>
|
|
<Field orientation="horizontal" className="max-w-xl">
|
|
<Switch id="ignore-repeated-teams" checked={ignoreRepeatedTeams} onCheckedChange={setIgnoreRepeatedTeams}
|
|
aria-labelledby="ignore-repeated-teams-label" aria-describedby="repeated-teams-description" />
|
|
<FieldContent>
|
|
<FieldLabel id="ignore-repeated-teams-label" htmlFor="ignore-repeated-teams">นับทีมซ้ำเพียงครั้งเดียว</FieldLabel>
|
|
<FieldDescription id="repeated-teams-description">ทีมที่มีตัวละคร อาวุธ เซ็ตอาร์ติแฟกต์ กลุ่มดาว และขัดเกลาเหมือนกัน จะนับครั้งเดียว ไม่ขึ้นกับลำดับสมาชิกหรือไกด์ที่บันทึก</FieldDescription>
|
|
</FieldContent>
|
|
</Field>
|
|
<details className="rounded-xl border p-4">
|
|
<summary className="cursor-pointer rounded-md font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
|
เลือกไกด์ที่นำมานับ ({formatCount(selected.guideCount + selected.stygianScheduleCount)} ไกด์)
|
|
</summary>
|
|
<div className="mt-4 flex flex-col gap-4">
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<p className="text-sm text-muted-foreground">กดปุ่มไกด์เพื่อไม่นับข้อมูล กดอีกครั้งเพื่อรวมกลับ</p>
|
|
<Button variant="outline" size="sm" disabled={!excludedGuideIds.size} onClick={() => setExcludedGuideIds(new Set())}>รวมทุกไกด์</Button>
|
|
</div>
|
|
{(["guide", "stygian"] as const).map((source) => <div key={source} className="flex flex-col gap-2">
|
|
<h3 className="text-sm font-medium">{source === "guide" ? "ไกด์ตัวละคร" : "เวอร์ชัน Stygian"}</h3>
|
|
<div className="flex flex-wrap gap-2">
|
|
{data.guides.filter((guide) => guide.source === source).map((guide) => {
|
|
const included = !excludedGuideIds.has(guide.id);
|
|
return <Button key={guide.id} variant={included ? "secondary" : "outline"} size="sm"
|
|
className="h-auto min-h-11 max-w-full whitespace-normal text-left"
|
|
aria-label={`นับไกด์ ${guide.name}`} aria-pressed={included} disabled={source === "stygian" && !includeStygian}
|
|
onClick={() => toggleGuide(guide.id)}>
|
|
{included ? <CheckIcon data-icon="inline-start" aria-hidden="true" /> : <MinusIcon data-icon="inline-start" aria-hidden="true" />}
|
|
{guide.name}
|
|
</Button>;
|
|
})}
|
|
</div>
|
|
</div>)}
|
|
</div>
|
|
</details>
|
|
<section aria-label="ภาพรวมข้อมูล" className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
|
|
{summaries.map(({ label, count }) => <Card key={label} size="sm">
|
|
<CardHeader><CardDescription>{label}</CardDescription></CardHeader>
|
|
<CardContent><p className="font-heading text-3xl font-semibold tabular-nums">{formatCount(count)}</p></CardContent>
|
|
</Card>)}
|
|
</section>
|
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
|
นับจากข้อมูลที่บันทึกในไกด์สาธารณะ ไม่ใช่สถิติการเล่นของผู้เล่น
|
|
แต่ละรายการแนะนำถูกนับแยกกัน รวมตัวเลือกซ้ำ
|
|
{ignoreRepeatedTeams ? " ทีมที่มีตัวละครและบิลด์เหมือนกันจะนับเพียงครั้งเดียวจากไกด์ที่เลือก" : " แต่ละสมาชิกทีมถูกนับแยกกัน รวมทีมที่คัดลอกข้ามไกด์หรือเวอร์ชัน"}
|
|
อาร์ติแฟกต์แบบผสมจะนับทั้งสองเซ็ต เซ็ตเดียวกันในรายการเดียวจะนับครั้งเดียว
|
|
ไกด์ที่ซ่อน อยู่ในถังขยะ และส่วนที่ปิดการแสดงผลจะไม่ถูกนับ
|
|
</p>
|
|
<section aria-labelledby="team-usage-title" className="flex flex-col gap-4">
|
|
<div className="flex flex-col gap-1">
|
|
<h2 id="team-usage-title" className="font-heading text-2xl font-semibold">ใช้ในทีมบ่อยที่สุด</h2>
|
|
<p className="text-sm text-muted-foreground">นับจำนวนครั้งที่ตัวละครและอุปกรณ์ปรากฏในสมาชิกทีม</p>
|
|
</div>
|
|
<div className="grid items-start gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
<UsageRanking title="ตัวละคร" description="ตัวละครที่ปรากฏในทีมบ่อยที่สุด" items={selected.team.character} />
|
|
<UsageRanking title="อาวุธ" description="อาวุธที่สมาชิกทีมใช้บ่อยที่สุด" items={selected.team.weapon} />
|
|
<UsageRanking title="อาร์ติแฟกต์" description="เซ็ตอาร์ติแฟกต์ที่สมาชิกทีมใช้บ่อยที่สุด" items={selected.team.artifact} />
|
|
</div>
|
|
</section>
|
|
<section aria-labelledby="recommendation-title" className="flex flex-col gap-4">
|
|
<div className="flex flex-col gap-1">
|
|
<h2 id="recommendation-title" className="font-heading text-2xl font-semibold">แนะนำบ่อยที่สุด</h2>
|
|
<p className="text-sm text-muted-foreground">นับรายการแนะนำอาวุธและอาร์ติแฟกต์ แยกจากอุปกรณ์ที่ใช้ในทีม{includeStygian ? " รวมอุปกรณ์ของตัวละครแนะนำใน Stygian" : ""}</p>
|
|
</div>
|
|
<div className="grid items-start gap-4 md:grid-cols-2">
|
|
<UsageRanking title="อาวุธแนะนำ" description="อาวุธที่ปรากฏในรายการแนะนำบ่อยที่สุด" items={selected.recommendation.weapon} />
|
|
<UsageRanking title="อาร์ติแฟกต์แนะนำ" description="เซ็ตอาร์ติแฟกต์ที่ปรากฏในรายการแนะนำบ่อยที่สุด" items={selected.recommendation.artifact} />
|
|
</div>
|
|
</section>
|
|
</div>;
|
|
}
|