自分が作成した二つのファイルを用いて理解を深める。
// このファイルは「メンバーのステータス」をレーダーチャートで表示するためのコンポーネントです。
"use client";
// App Routerでは、Rechartsのようなブラウザ依存ライブラリを使う場合、
// このコンポーネントはクライアント側で実行される必要があります。
// Rechartsから必要な部品をインポート
// Reactでは「必要なものだけをimportする」ことが基本です。
import {
Radar, // 実際のレーダー(五角形の塗り部分)
RadarChart, // レーダーチャート全体の枠
PolarGrid, // 背景のグリッド線
PolarAngleAxis, // 各ステータスのラベル軸(ビジュアルなど)
PolarRadiusAxis, // 0〜5のスケール軸
ResponsiveContainer,// 親要素に合わせて自動サイズ調整
} from "recharts";
// Propsの型定義
// TypeScriptでは「このコンポーネントが何を受け取るか」を明示する
type Props = {
status: {
visual: number;
singing: number;
dancing: number;
variety: number;
leadership: number;
};
color?: string;
// ? は「省略可能」という意味
// 指定されなければデフォルト色を使う
};
// 関数コンポーネント
// ReactではUIを「関数」として定義する
export function MemberRadarChart({ status, color = "#812990" }: Props) {
// 分割代入を使ってpropsを受け取る
// color = "#812990" はデフォルト値設定
// Recharts用にデータ形式を整形
// APIのままではなく、「表示用データ」に変換するのが重要
const data = [
{ subject: "ビジュアル", value: status.visual },
{ subject: "歌唱力", value: status.singing },
{ subject: "ダンス", value: status.dancing },
{ subject: "バラエティ", value: status.variety },
{ subject: "リーダー", value: status.leadership },
];
return (
// Tailwindで高さを指定(h-80)
// ResponsiveContainerは高さが無いと表示されない
<div className="w-full h-80">
<ResponsiveContainer>
{/* レーダーチャート全体 */}
<RadarChart
data={data}
// 余白を設定(見た目調整)
margin={{ top: 20, right: 40, bottom: 20, left: 40 }}
>
{/* 背景のグリッド線 */}
<PolarGrid />
{/* 各ステータスのラベル表示 */}
<PolarAngleAxis dataKey="subject" />
{/* 0〜5のスケール軸 */}
<PolarRadiusAxis
domain={[0, 5]} // 最小0、最大5
tick={false} // 数値は非表示
axisLine={false}// 軸線非表示
tickLine={false}// 目盛り線非表示
/>
{/* 実際のステータス描画部分 */}
<Radar
name="ステータス"
dataKey="value" // dataの中のvalueを参照
stroke={color} // 線の色
fill={color} // 塗りの色
fillOpacity={0.6}// 透明度
/>
</RadarChart>
</ResponsiveContainer>
</div>
);
}
重要な部分だけにコメントを入れます(全部に入れると逆に読みにくくなるため)。
// Next.jsのLinkコンポーネント(クライアント遷移用)
import Link from "next/link";
// 自作レーダーチャートコンポーネントをインポート
// @ はtsconfig.jsonで設定したエイリアス
import { MemberRadarChart } from "@/components/MemberRadarChart";
// ==========================
// 型定義エリア(TypeScript)
// ==========================
// ステータスの型
// APIのレスポンス構造と一致させることが重要
export type MemberStatus = {
visual: number;
singing: number;
dancing: number;
variety: number;
leadership: number;
};
// メンバー全体の型
// 「設計図」の役割を持つ
export type Member = {
id: number;
name: string;
nickname: string;
generation: number;
birthday: string;
description: string;
status: MemberStatus;
};
// ==========================
// データ取得関数
// ==========================
// async関数 = 非同期処理
// fetchはPromiseを返すためasync/awaitを使用
async function getMember(id: string): Promise<Member> {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/members/${id}`,
{ cache: "no-store" } // 毎回最新取得
);
if (!res.ok) {
throw new Error("Failed to fetch member");
}
return res.json();
}
// ==========================
// メンバー詳細ページ
// ==========================
// App Routerではpage.tsxはサーバーコンポーネント
export default async function MemberDetail({
params,
}: {
params: Promise<{ id: string }>;
}) {
// URLパラメータ取得
const { id } = await params;
// APIからデータ取得
const member = await getMember(id);
// generationに応じたテーマを取得
const theme = generationTheme[member.generation] ?? defaultTheme;
return (
<main className={`min-h-screen ${theme.bg}`}>
{/* ヒーローエリア */}
<div className={`${theme.hero} text-white py-16 px-6`}>
<div className="max-w-4xl mx-auto flex flex-col items-center text-center gap-4">
{/* 期生バッジ */}
<span className="inline-block bg-white/20 text-white text-sm font-semibold px-4 py-1 rounded-full tracking-widest">
{member.generation}期生
</span>
{/* メンバー名 */}
<h1 className="text-4xl md:text-5xl font-bold tracking-wide">
{member.name}
</h1>
{/* ニックネーム */}
<p className="text-white/80 text-lg">
“{member.nickname}”
</p>
</div>
</div>
{/* コンテンツエリア */}
<div className="max-w-4xl mx-auto px-6 py-12">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* プロフィールカード */}
<div className={`bg-white rounded-2xl shadow-sm border ${theme.border} p-8 flex flex-col gap-6`}>
<h2 className={`text-lg font-bold ${theme.title} border-b ${theme.border} pb-3`}>
プロフィール
</h2>
{/* プロフィール内容 */}
<div className="flex flex-col gap-4">
<div>
<p className="text-xs text-gray-400 uppercase tracking-widest mb-1">
ニックネーム
</p>
<p className="text-gray-700 font-medium">
{member.nickname}
</p>
</div>
{/* 他項目も同様に表示 */}
</div>
</div>
{/* ステータスカード */}
<div className={`bg-white rounded-2xl shadow-sm border ${theme.border} p-8 flex flex-col gap-6`}>
<h2 className={`text-lg font-bold ${theme.title} border-b ${theme.border} pb-3`}>
ステータス
</h2>
{/* レーダーチャート表示 */}
<div className="flex items-center justify-center">
<MemberRadarChart
status={member.status} // APIのstatusを渡す
color={theme.chart} // 期生カラーを渡す
/>
</div>
</div>
</div>
{/* 戻るリンク */}
<div className="mt-10 text-center">
<Link
href="/members"
className={`inline-flex items-center gap-2 ${theme.link} font-medium transition-colors`}
>
← メンバー一覧に戻る
</Link>
</div>
</div>
</main>
);
}
ただし、