**بحث سر اینکه کدام بازیکن بهتر است هیچوقت تمام نمیشود!** چون هر طرف آماری را میگوید که به نفع انتخاب خودش است. ولی وقتی همان عددها را کنار هم بگذارید، بحث از سلیقه بیرون میآید! در هر آمار معلوم میشود کدام بازیکن جلوتر است و اختلافشان چقدر است. در این سوال قرار است همین ابزار داوری را بسازید. **دوین** بین بازیکنها جستوجو میکند، حداکثر تا سه نفر را برای مقایسه انتخاب میکند و آمارشان را در یک **جدول رودررو** و یک **نمودار راداری** کنار هم میبیند. پیادهسازی کل این چالش با `React` و `TypeScript` است.

**هدف این سوال پیادهسازی نمایش و رفتار داده است.** فهرست بازیکنها با جستوجو و فیلتر و مرتبسازی، جدول رودررو و رسم نمودار راداری از روی هندسهای که **از قبل برایتان پیادهسازی شده است!** چیدمان کلی برنامه، مسیریابی، هدر، نوار مقایسه و کشوی منتخبها همگی آمادهاند و نباید بازنویسیشان کنید.
# **پروژهٔ اولیه**
**پروژهٔ اولیه** را از [این لینک](/contest/assignments/103143/download_problem_initial_project/356841/) دانلود کنید. این سوال یک پروژهٔ `Vite` با `React` و `TypeScript` است.
<details class="green">
<summary>**ساختار فایلها و پروژه اولیه**</summary>
```plaintext
initial_project/
├─ src/
│ ├─ data/
│ │ └─ <mark class="green" title="پیادهسازی شده؛ آماده است و نباید تغییر کند">players.ts</mark>
│ ├─ engine/
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">compare.ts</mark>
│ │ ├─ <mark class="green" title="پیادهسازی شده؛ آماده است و نباید تغییر کند">radar.ts</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">query.ts</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">shortlist.ts</mark>
│ │ └─ <mark class="orange" title="باید پیادهسازی شود">format.ts</mark>
│ ├─ hooks/
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">useCompare.ts</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">useShortlist.ts</mark>
│ │ └─ <mark class="orange" title="باید پیادهسازی شود">usePlayerQuery.ts</mark>
│ ├─ components/
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">PlayerCard.tsx</mark>
│ │ ├─ <mark class="green" title="پیادهسازی شده؛ آماده است و نباید تغییر کند">CompareBar.tsx</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">RadarChart.tsx</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">H2HTable.tsx</mark>
│ │ ├─ <mark class="green" title="پیادهسازی شده؛ آماده است و نباید تغییر کند">ShortlistDrawer.tsx</mark>
│ │ └─ <mark class="green" title="پیادهسازی شده؛ آماده است و نباید تغییر کند">ThemeToggle.tsx</mark>
│ ├─ pages/
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">Home.tsx</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">Compare.tsx</mark>
│ │ └─ <mark class="orange" title="باید پیادهسازی شود">PlayerDetail.tsx</mark>
│ └─ <mark class="green" title="پیادهسازی شده؛ آماده است و نباید تغییر کند">App.tsx</mark>
```
+ **نکته:** داخل فایلهای پروژهٔ اولیه، در هر بخش کامنتهایی جهت انجام راهنمایی برای پیادهسازی قرار گرفتهاند: قرارداد هر تابع، حالتهای مرزی و نکتههای پیادهسازی. پیش از شروع هر فایل، کامنتهای بالای آن را بخوانید.
+ **نکته:** درخت بالا ساختار پروژه است. فایلهای **سبز آمادهاند و نباید تغییرشان بدهید**؛ فایلهای نارنجی همانهاییاند که باید تکمیلشان کنید. منطق در `engine/` نوشته میشود، هوکها در `hooks/` وضعیت را نگه میدارند و کامپوننتها فقط نمایش میدهند.
+ **نکته:** دقت کنید که `App.tsx` هم آماده است: مسیریابی، هدر، نگهداری سبد مقایسه و فهرست منتخب بالای `Routes`، و جاگذاری نوار مقایسه و کشوی منتخبها همگی نوشته شدهاند. پس هیچوقت درگیر سیمکشی برنامه نمیشوید و تمرکزتان روی صفحهها و کامپوننتهای دادهمحور میماند.
+ **نکته:** این لایهبندی عمدی است: چون **توابع** `engine` به `DOM` و `localStorage` کاری ندارند، سیستم داوری میتوانند مستقیم و بدون رندر صدایشان بزند.
در هر فایل، اسکلت همهٔ `export`های لازم با بدنهٔ `TODO` آمده است. نام فایلها و نام `export`ها را تغییر ندهید.
</details>
<details class="green">
<summary>**نکته: آنچه از قبل آماده و در فایل پروژه اولیه پیادهسازی شده است**</summary>
**فایل** `src/data/players.ts` اینها را `export` میکند:
+ `StatKey` که یکی از `pace`، `shooting`، `passing`، `dribbling`، `defending` و `physical` است
+ `Player` تایپ بازیکن، شامل `id`، `name`، `nation`، `club`، `position`، `age`، `overall` و `stats`
+ `STAT_KEYS` فهرست شش کلید آماری به ترتیب
+ `STAT_LABELS` نام نمایشی هر آمار
+ `PLAYERS` فهرست کامل بازیکنها
+ `findPlayer(id)` که بازیکن را با شناسهاش پیدا میکند و برای شناسهٔ ناشناخته `undefined` میدهد
</details>
# **جزئیات پیادهسازی**
برنامه سه صفحه دارد: فهرست بازیکنها در `/`، صفحهٔ مقایسه در `/compare` و صفحهٔ جزئیات یک بازیکن در `/player/:id`.
|  |
| :-: |
| افزودن بازیکن به سبد و رسم نمودار رادار؛ همین جریان را در اجرای واقعی برنامه نشان میدهد |
+ **نکته:** توابع `engine` باید **خالص** *(Pure)* باشند. یعنی:
+ به `DOM` و `localStorage` دسترسی نداشته باشند. تنها استثنا `shortlist.ts` است که `Storage` را بهعنوان پارامتر میگیرد.
+ نه آرایهٔ ورودی و نه شیءهای داخل آن را تغییر ندهند.
+ دقت کنید `Array.prototype.sort` آرایه را در جای خود مرتب میکند؛ روی یک کپی کار کنید یا از `toSorted` استفاده کنید.
<details class="blue">
<summary>**پیادهسازی `engine/compare.ts`**</summary>
سبد مقایسه فقط یک آرایه از شناسهٔ بازیکنهاست، ولی یک قرارداد دارد که سیستم داوری دقیقاً روی آن حساب میکند: **وقتی عملیات هیچ تغییری ایجاد نمیکند، باید همان آرایهٔ ورودی برگردد، نه یک کپی تازه.** رابط کاربری از روی همین برابری مرجع تشخیص میدهد که چیزی عوض نشده و لازم نیست دوباره رندر کند. هر جا واقعاً تغییری رخ میدهد، آرایهٔ تازه برمیگردد و ورودی دستنخورده میماند. در قسمت زیر تعاریف و نحوه پیادهسازی هر کدام از توابع این ماژول بررسی میشوند:
سبد مقایسه حداکثر سه بازیکن میگیرد:
```ts
export const MAX_COMPARE = 3;
```
> این ثابت سقف تعداد بازیکنهای قابل مقایسه است. همهٔ توابع سبد مقایسه به همین مقدار تکیه میکنند، پس عدد را در کد پخش نکنید و همهجا از همین ثابت بخوانید.
>
> رابط کاربری هم بر اساس همین مقدار دکمهٔ افزودن بازیکنهای خارج از سبد را غیرفعال میکند.
- **تابع `addToCompare(ids, id)`:** شناسهای را به سبد اضافه میکند و آرایهٔ تازه برمیگرداند. دو حالت هست که در آنها چیزی اضافه نمیشود و طبق قرارداد بالا **همان آرایهٔ ورودی** برمیگردد: وقتی بازیکن از قبل در سبد باشد و وقتی سبد به سقف `MAX_COMPARE` رسیده باشد.
- **تابع `removeFromCompare(ids, id)`:** شناسه را از سبد بیرون میآورد و آرایهٔ تازه برمیگرداند. اگر آن شناسه اصلاً در سبد نباشد، تغییری در کار نیست.
- **تابع `toggleCompare(ids, id)`:** همان کاری را میکند که کلیک روی دکمهٔ افزودن یا حذف انجام میدهد: اگر بازیکن در سبد نبود اضافهاش میکند و اگر بود حذفش میکند. سقف سبد اینجا هم رعایت میشود.
- **تابع `canAddMore(ids)`:** میگوید هنوز جای خالی در سبد مانده است یا نه و مقدار بولی برمیگرداند. رابط کاربری با همین مقدار، دکمهٔ افزودن بازیکنهای خارج از سبد را غیرفعال میکند.
```js
addToCompare([], "amir") // ["amir"]
addToCompare(["amir"], "amir") // ["amir"]
addToCompare(["amir","leo","kalu"], "hugo") // unchanged
toggleCompare(["amir","leo"], "amir") // ["leo"]
canAddMore(["amir","leo","kalu"]) // false
```
> بلوک بالا رفتار سبد مقایسه را نشان میدهد. افزودن بازیکنی که از قبل در سبد است یا افزودن به سبد پر، هیچ تغییری نمیدهد و در این حالت **همان آرایهٔ ورودی** برگردانده میشود، نه یک کپی تازه. این تفاوت مهم است چون رابط کاربری از روی همین تشخیص میدهد که چیزی عوض نشده.
>
> هروقت واقعاً تغییری رخ میدهد، آرایهٔ تازهای برمیگردد و آرایهٔ ورودی دستنخورده میماند. تابع `toggleCompare` هم بسته به حضور بازیکن، یکی از دو تابع افزودن یا حذف را انجام میدهد.
تابع `buildStatRows(players)` برای هر آمار یک ردیف میسازد، به همان ترتیب `STAT_KEYS`. هر ردیف این شکل را دارد:
```ts
{ key: "pace", label: "Pace", values: [88, 71], best: 88, winners: [0] }
```
> این شکل یک ردیف از جدول رودرروست. مقدار `values` آمار همان ویژگی برای بازیکنهای سبد است و ترتیبش با ترتیب سبد یکی است. مقدار `best` بهترین عدد و `winners` اندیس بازیکنهایی است که به آن رسیدهاند.
>
> فیلد `winners` عمداً آرایه است تا حالت تساوی هم پوشش داده شود؛ اگر دو بازیکن عدد یکسان داشته باشند، هر دو اندیس در آن میآیند و در جدول هم هر دو باید متمایز شوند.
سه قاعده روی هر ردیف برقرار است: مقدار `best` بیشترین عدد آن ردیف است؛ آرایهٔ `winners` اندیس **همهٔ** بازیکنهایی است که به آن عدد رسیدهاند، پس در تساوی هر دو اندیس داخلش میآیند؛ و اگر فقط یک بازیکن در سبد باشد `winners` خالی میماند، چون مقایسهای در کار نیست و نباید یک بازیکن تنها برندهٔ خودش اعلام شود.
- **تابع `statWinCounts(players)`:** برای هر بازیکن میشمارد در چند آمار **قطعاً** جلوتر بوده و آرایهای از این تعدادها برمیگرداند، به همان ترتیب بازیکنهای سبد. کلمهٔ «قطعاً» اینجا کلیدی است: آماری که مساوی شده برای هیچکس برد حساب نمیشود، پس برای دو بازیکن کاملاً یکسان خروجی `[0, 0]` است.
- **تابع `overallWinner(players)`:** اندیس بازیکنی را برمیگرداند که بیشترین تعداد برد آماری را دارد. دو حالت `-1` میدهد: وقتی کمتر از دو بازیکن در سبد باشد و وقتی چند بازیکن در بیشترین تعداد برد مساوی شوند. یعنی این تابع فقط وقتی برنده اعلام میکند که برنده یکتا باشد.
- **تابع `statDelta(a, b, key)`:** اختلاف علامتدار یک آمار مشخص را میان دو بازیکن برمیگرداند و عدد خروجی از دید بازیکن اول است؛ عدد مثبت یعنی بازیکن اول جلوتر است.
- **تابع `deltaVector(a, b)`:** همان اختلاف را برای **همهٔ** شش آمار یکجا حساب میکند. خروجی یک **شیء** است که برای هر کلید از `STAT_KEYS` یک ورودی دارد، نه یک آرایه. نمودار اختلاف در رابط کاربری از همین شیء تغذیه میشود.
</details>
<details class="green">
<summary>**نکته: هندسهٔ نمودار رادار که از قبل آماده است**</summary>
**نمودار رادار** همان نمودار عنکبوتی است که در بازیهای فوتبال برای مقایسهٔ ویژگیهای بازیکنها میبینید: چند محور که از یک مرکز بیرون میزنند و برای هر بازیکن یک چندضلعی که رأسهایش روی این محورها مینشیند.
**ریاضیات این نمودار از قبل در `engine/radar.ts` نوشته شده است و نباید تغییرش بدهید.** در کار واقعی هم کسی این محاسبات را از صفر نمینویسد؛ کاری که از شما خواسته میشود **رسم** نمودار از روی همین خروجی آماده است، نه بازسازی هندسهاش. اینها را از `radar.ts` بگیرید:
| **تابع** | **چه میدهد** |
| :-: | :-: |
| `normalizeVector(values, max = 100)` | آرایهٔ آمار خام را به آرایهای از کسرهای بین `0` و `1` تبدیل میکند |
| `buildGeometry(count, center, radius, ringCount = 4)` | اسکلت نمودار: نقطهٔ انتهایی هر محور در `axes` و شعاع حلقههای راهنما در `rings` |
| `polygonPoints(values, center, radius)` | رأسهای چندضلعی یک بازیکن از روی آرایهٔ نرمالشده |
| `pointsToAttr(points)` | همان رأسها به شکل رشتهٔ آمادهٔ ویژگی `points` در `SVG` |
شکل دادههایی که با آنها کار میکنید:
```ts
export interface Point { x: number; y: number; }
export interface RadarGeometry {
center: Point;
radius: number;
axes: Point[]; // the end point of each axis
rings: number[]; // ring radii, inner to outer; the last one equals radius
}
```
> این دو تایپ خروجی `buildGeometry` را توصیف میکنند. مقدار `center` مرکز نمودار و `radius` شعاع بیرونی آن است. آرایهٔ `axes` برای هر آمار یک نقطه دارد که انتهای محور همان آمار است و از رویش هم خط محور و هم جای برچسب را میسازید.
>
> آرایهٔ `rings` فقط شعاع حلقههای راهنماست، نه نقطه؛ برای هر عضو آن یک دایره با همان شعاع حول `center` رسم میکنید. محور شمارهٔ صفر رو به بالاست و بقیه با فاصلهٔ مساوی دور دایره چیده شدهاند، پس ترتیب `axes` دقیقاً با ترتیب `STAT_KEYS` میخواند.
|  |
| :-: |
| صفحهٔ مقایسه با نمودار رادار و جدول رودررو؛ نمای این بخش پس از پیادهسازی درست |
</details>
<details class="violet">
<summary>**پیادهسازی `engine/query.ts`**</summary>
این ماژول فهرست بازیکنها را به چیزی تبدیل میکند که روی صفحه دیده میشود: اول فیلترها را اعمال میکند و بعد نتیجه را مرتب میکند. توابعش خالصاند و آرایهٔ ورودی را تغییر نمیدهند. در قسمت زیر تعاریف و نحوه پیادهسازی هر کدام از توابع این ماژول بررسی میشوند:
```ts
export interface QueryState {
search: string;
position: Position | "all";
sortKey: "overall" | "name" | "age" | StatKey;
sortDir: "asc" | "desc";
}
export const DEFAULT_QUERY: QueryState = {
search: "", position: "all", sortKey: "overall", sortDir: "desc",
};
```
> این ساختار وضعیت جستوجو و فیلتر و مرتبسازی را یکجا نگه میدارد. همهٔ صفحهٔ فهرست از روی همین یک شیء رندر میشود، پس هر تغییری در جستوجو یا فیلتر فقط یک فیلد از آن را عوض میکند.
>
> نگهداشتن همهچیز در یک شیء باعث میشود بازنشانی فیلترها بهسادگیِ برگرداندن مقدار پیشفرض باشد.
- **تابع `matchesSearch(player, search)`:** میگوید یک بازیکن با عبارت جستوجو میخواند یا نه. جستوجو در سه فیلد **نام و ملیت و باشگاه** انجام میشود، بهصورت **زیررشتهای** یعنی بخشی از کلمه هم کافی است و **بدون حساسیت به بزرگی و کوچکی حروف**. عبارت جستوجوی خالی همهٔ بازیکنها را قبول میکند.
- **تابع `matchesPosition(player, position)`:** میگوید بازیکن با فیلتر پست میخواند یا نه. مقدار `"all"` یعنی فیلتری در کار نیست و همه قبولاند؛ در غیر این صورت پست بازیکن باید دقیقاً برابر مقدار دادهشده باشد. مقادیر معتبر `"GK"` و `"DF"` و `"MF"` و `"FW"` هستند.
|  |
| :-: |
| جستوجو و فیلتر فهرست بازیکنها؛ نمای این بخش پس از پیادهسازی درست |
- **تابع `filterPlayers(players, state)`:** هر دو فیلتر بالا را با هم روی فهرست اعمال میکند و فهرست تازه برمیگرداند؛ بازیکنی میماند که **هر دو** شرط را داشته باشد.
- **تابع `sortPlayers(players, key, dir)`:** فهرست را بر اساس یکی از `overall` یا `name` یا `age` یا هر کدام از شش آمار مرتب میکند و جهتش با `dir` مشخص میشود. یک شرط مهم دارد که سیستم داوری میسنجد: مرتبسازی باید **پایدار** باشد، یعنی وقتی دو بازیکن در کلید مرتبسازی مساویاند، ترتیب نسبیشان در ورودی حفظ شود. بدون این شرط، فهرست با هر بار رندر جابهجا میشود و کاربر سردرگم میشود.
- **تابع `queryPlayers(players, state)`:** نقطهٔ ورود این ماژول است و کل مسیر را یکجا انجام میدهد: اول فیلتر و بعد مرتبسازی، بر اساس همان شیء `QueryState`. ترتیب این دو مرحله اهمیت دارد. مقدار پیشفرض `DEFAULT_QUERY` هم یعنی بدون فیلتر، مرتبشده بهصورت نزولی بر اساس `overall`.
</details>
<details class="orange">
<summary>**پیادهسازی `engine/format.ts` و `engine/shortlist.ts`**</summary>
فایل `format.ts` توابع کوچکی دارد که عدد خام را به رشتهٔ آمادهٔ نمایش تبدیل میکنند. هدفشان این است که هیچ کامپوننتی خودش رشته نسازد و قالب اعداد در کل برنامه یکدست بماند. این توابع **خالص**اند: فقط از ورودیشان رشته میسازند و به حالت برنامه یا `DOM` کاری ندارند. شش تابع زیر را بنویسید و از همین فایل خروجی بگیرید:
+ `formatDelta(delta)` عدد اختلاف را به رشته تبدیل میکند، بهطوری که علامت مثبت هم در خروجی دیده شود.
+ `clamp(value, min, max)` مقدار را داخل بازهٔ `[min, max]` نگه میدارد و **عدد** برمیگرداند، نه رشته.
+ `formatScore(value)` امتیاز اعشاری را با یک رقم اعشار برمیگرداند، ولی اگر عدد رُند شده صحیح شد، اعشار اضافه را نمینویسد.
+ `playerMeta(position, nation, age)` سه مشخصهٔ بازیکن را به یک زیرعنوان کوتاه میچسباند. جداکنندهٔ میان آنها دقیقاً `" · "` است.
+ `selectionLabel(count)` برچسب نوار مقایسه را میسازد و برای حالت صفر، حالت یک و حالت بیشتر از یک، سه رشتهٔ متفاوت میدهد.
+ `percent(value)` عدد صفر تا صد را به رشتهٔ درصد با عدد صحیح تبدیل میکند.
خروجی دقیق هر تابع را از نمونهٔ زیر بردارید؛ سیستم داوری رشته را کاراکتربهکاراکتر میسنجد:
```js
formatDelta(5) // "+5"
formatDelta(-3) // "-3"
formatDelta(0) // "0"
clamp(120, 0, 100) // 100
formatScore(8.25) // "8.3"
formatScore(8) // "8"
playerMeta("FW", "Iran", 24) // "FW · Iran · 24"
selectionLabel(0) // "No players selected"
selectionLabel(1) // "1 player selected"
percent(41.6) // "42%"
```
> بلوک بالا قالببندی اختلاف آمار را نشان میدهد. عدد مثبت با علامت `+` و عدد منفی با علامت خودش نمایش داده میشود، تا در جدول معلوم باشد کدام بازیکن جلوتر است.
>
> برای اختلاف صفر هم باید قالب مشخصی برگردد و خروجی همیشه رشته است، چون مستقیم داخل سلول جدول مینشیند.
فهرست منتخب در `localStorage` ذخیره میشود:
```ts
export const SHORTLIST_KEY = "versus.shortlist.v1";
```
> این کلید همان جایی است که فهرست منتخب زیر آن در `localStorage` ذخیره میشود. پسوند نسخه در نامش عمدی است تا اگر شکل داده عوض شد، دادهٔ قدیمی با نسخهٔ تازه قاتی نشود.
>
> توابع ذخیره و بازیابی، `Storage` را بهعنوان پارامتر میگیرند نه اینکه مستقیم سراغ `localStorage` بروند؛ همین باعث میشود بشود بدون مرورگر هم تستشان کرد.
فهرست منتخب با سبد مقایسه فرق دارد: سبد مقایسه موقتی است و سقف دارد، ولی فهرست منتخب سقف ندارد و بین اجراهای مختلف برنامه باقی میماند، چون در `localStorage` ذخیره میشود. در قسمت زیر تعاریف و نحوه پیادهسازی هر کدام از توابع این ماژول بررسی میشوند:
- **توابع `toggleShortlist(ids, id)` و `isShortlisted(ids, id)`:** اولی شناسه را به فهرست منتخب اضافه یا از آن حذف میکند و **آرایهٔ تازه** برمیگرداند؛ آرایهٔ ورودی دستنخورده میماند. دومی میگوید این شناسه در فهرست هست یا نه و مقدار بولی برمیگرداند.
- **تابع `loadShortlist(storage = localStorage)`:** فهرست ذخیرهشده را میخواند و آرایهای از شناسهها برمیگرداند. مهمترین نکتهاش مقاومت در برابر دادهٔ خراب است: اگر هنوز چیزی ذخیره نشده باشد، اگر مقدار ذخیرهشده `JSON` معتبری نباشد، یا اگر شکل داده آن چیزی نباشد که انتظار میرود، باید آرایهٔ **خالی** برگردد و هیچ خطایی پرتاب نشود. حافظهٔ مرورگر را کاربر یا افزونهها هم میتوانند دستکاری کنند و برنامه نباید با یک مقدار خراب بالا نیاید.
- **تابع `saveShortlist(ids, storage = localStorage)`:** فهرست را زیر همان کلید ذخیره میکند.
+ **نکته:** پارامتر `storage` عمداً تزریق میشود بهجای اینکه این توابع مستقیم سراغ `localStorage` بروند. اینطور میشود در سیستم داوری یک حافظهٔ ساختگی داد و رفتار توابع را بدون مرورگر سنجید. حواستان باشد که مقدار پیشفرضش همان `localStorage` است، پس در کد رابط کاربری لازم نیست چیزی پاس بدهید.
</details>
<details class="teal">
<summary>**هوکها، کامپوننتها و صفحهها**</summary>
ماژولهای `engine` توابع خالص بودند و هیچ حالتی نگه نمیداشتند. هوکها همان توابع را به حالت `React` وصل میکنند: حالت را نگه میدارند، توابع `engine` را روی آن اجرا میکنند و یک `API` تمیز به کامپوننتها میدهند. سه هوک لازم است:
- **هوک `useCompare(initial = [])`:** سبد مقایسه را نگه میدارد. شیئی برمیگرداند که هم دادهٔ فعلی را دارد و هم توابع تغییرش: فهرست شناسهها، تعدادشان، اینکه آیا جای خالی مانده، تابعی برای پرسیدن اینکه یک بازیکن در سبد هست یا نه و توابع افزودن و حذف و جابهجایی و خالی کردن سبد. خودِ منطق سقف و تکراریها را دوباره ننویسید؛ این هوک فقط توابع `compare.ts` را روی حالت اجرا میکند.
- **هوک `useShortlist()`:** فهرست منتخب را نگه میدارد و تفاوت اصلیاش با هوک قبلی **ماندگاری** است: هنگام نخستین رندر مقدارش را از حافظهٔ مرورگر میخواند و هر بار که فهرست عوض میشود، دوباره ذخیرهاش میکند. شیء برگشتیاش فهرست شناسهها، تعدادشان، تابع پرسش عضویت و توابع جابهجایی و خالی کردن را دارد.
- **هوک `usePlayerQuery(players)`:** حالت جستوجو و فیلتر و مرتبسازی را نگه میدارد و **فهرست نهایی بازیکنهای قابل نمایش** را از رویش میسازد. شیء برگشتیاش هم فیلدهای فعلی `QueryState` را دارد، هم نتیجهٔ نهایی و هم توابعی برای عوض کردن هر فیلد، برعکس کردن جهت مرتبسازی و بازنشانی به حالت پیشفرض. محاسبهٔ فهرست نهایی باید فقط وقتی دوباره انجام شود که ورودیها عوض شده باشند، نه در هر رندر.
برای سازگاری با سیستم داوری، هر کامپوننت باید `default export` داشته باشد.
برای `CompareBar` و `ShortlistDrawer` ساختار و ظاهر آزاد است؛ فقط باید در جریان اصلی برنامه قابل استفاده باشند و عنصرهای `data-testid`دار گفتهشده را در خود داشته باشند. کامپوننت `ThemeToggle` هم در ارزیابی امتیازی ندارد و پیادهسازیاش کاملاً آزاد است.
|  |
| :-: |
| صفحهٔ جزئیات یک بازیکن؛ نمای این بخش پس از پیادهسازی درست |
**صفحهٔ فهرست (`/`):**
+ برای هر بازیکن یک کارت با `data-testid="player-card-<id>"`.
+ ورودی جستوجو با `data-testid="search-input"` که با تایپ، کارتها را فیلتر میکند.
+ اگر هیچ بازیکنی با فیلتر جور نبود، یک `empty-state` نشان داده میشود.
+ روی هر کارت دو دکمه: `compare-toggle-<id>` برای افزودن به سبد مقایسه و `shortlist-toggle-<id>` که همیشه `aria-pressed` دارد؛ `"false"` وقتی بازیکن در فهرست منتخب نیست و `"true"` وقتی هست.
+ وقتی سه بازیکن انتخاب شده باشند، دکمهٔ `compare-toggle-<id>` برای بازیکنهایی که در سبد نیستند باید `disabled` شود. دکمهٔ بازیکنهای داخل سبد فعال میماند تا بشود حذفشان کرد.
+ لینک رفتن به صفحهٔ جزئیات با `data-testid="player-link-<id>"`.
+ دکمهٔ `open-compare` برای رفتن به صفحهٔ مقایسه.
+ **نکته:** سبد مقایسه و فهرست منتخب باید در `App.tsx` و **بالای** `Routes` نگهداری شوند. اگر این حالت را داخل صفحهٔ فهرست بگذارید، با رفتن به صفحهٔ مقایسه پاک میشود و آن صفحه خالی بالا میآید.
|  |
| :-: |
| صفحهٔ فهرست بازیکنها؛ نمای این بخش پس از پیادهسازی درست |
**صفحهٔ مقایسه (`/compare`):**
+ نمودار رادار با `data-testid="radar-chart"` که خودش عنصر `svg` است و باید هم `role="img"` داشته باشد و هم `aria-label`.
+ کامپوننت `RadarChart` یک پراپ اجباری دارد: `players: Player[]`. سیستم داوری آن را **مستقل** و بیرون از `App` و بدون هیچ `Context` یا `Router` رندر میکند، پس نباید به چیزی جز همین پراپ وابسته باشد.
+ برای هر بازیکن یک چندضلعی با `data-testid="radar-shape-<id>"` که ویژگی `points` دارد.
+ برای هر آمار یک برچسب محور با `data-testid="radar-label-<statKey>"`.
+ حلقههای راهنمای نمودار با `radar-ring-0` و شمارههای بعدی.
+ برای هر بازیکن یک ردیف راهنما با `data-testid="legend-<id>"`.
+ جدول رودررو که برای هر آمار و هر بازیکن یک سلول با `data-testid="h2h-cell-<statKey>-<id>"` دارد. سلول بازیکن برنده در هر آمار باید بهصورت دیداری از بقیه متمایز باشد. در صورت تساوی، همهٔ بازیکنهای برنده به یک شکل متمایز میشوند.
**صفحهٔ جزئیات (`/player/:id`):**
+ عنصری با `data-testid="player-detail"`.
+ اگر شناسه پیدا نشد، **فقط** `player-not-found` رندر شود و عنصر `player-detail` اصلاً در صفحه نباشد.
**تم:** دکمهٔ تغییر تم که وضعیتش را روی `aria-pressed` نشان میدهد.
|  |
| :-: |
| صفحهٔ مقایسه در تم تیره؛ نمای این بخش پس از پیادهسازی درست |
</details>
# **آنچه سیستم داوری بررسی میکنند**
سیستم داوری توابع `engine` را مستقیم `import` میکنند، هر کامپوننت را جدا `render` میکند و در پایان کل برنامه را اجرا میکند. **رنگها، فونتها و اندازهٔ پیکسلی در سیستم داوری بررسی نمیشوند!** آنچه بررسی میشود به شکل زیر است:
+ مقدار بازگشتی هر تابع برای ورودیهای مشخص، دقیقاً مطابق مثالهای بالا
+ برگرداندن **همان آرایهٔ ورودی** در `addToCompare` وقتی تغییری لازم نیست
+ ترتیب ردیفها در `buildStatRows` که باید برابر `STAT_KEYS` باشد
+ رو به بالا بودن محور صفر و فاصلهٔ مساوی محورها در `axisAngle`
+ گرد شدن مختصات `pointAt` به دو رقم اعشار
+ **برگشت** آرایهٔ خالی در `loadShortlist` برای مقدار خراب
+ **وجود** `default export` برای هر کامپوننت
+ **وجود عناصر** با `data-testid`های گفتهشده و ویژگیهای `aria-pressed` و `aria-label`
+ **وضعیت** `disabled` دکمههای مقایسه وقتی سبد پر است
+ **ذخیره** و **بازیابی** فهرست منتخب زیر کلید `versus.shortlist.v1`
آنچه در سیستم داوری بررسی **نمیشود** و در پیادهسازی آن میتوانید آزادانه و دلخواه عمل کنید:
+ **شعاع** و **اندازهٔ** نمودار رادار و تعداد حلقههای راهنما
+ **رنگ و استایل** سلول برنده در جدول رودررو
+ **چیدمان** و **طراحی بصری** صفحه
|  |
| :-: |
| فهرست منتخب ذخیرهشده؛ نمای این بخش پس از پیادهسازی درست |
# **آنچه باید آپلود کنید**
- **توجه:** فایل زیپ شما باید همان ساختار پروژهٔ اولیه را داشته باشد و پوشهٔ `node_modules` را نداشته باشد.
```plaintext
└── src/
├── components/
│ ├── CompareBar.tsx
│ ├── H2HTable.tsx
│ ├── PlayerCard.tsx
│ ├── RadarChart.tsx
│ ├── ShortlistDrawer.tsx
│ └── ThemeToggle.tsx
├── engine/
│ ├── compare.ts
│ ├── format.ts
│ ├── query.ts
│ ├── radar.ts
│ └── shortlist.ts
├── hooks/
│ ├── useCompare.ts
│ ├── usePlayerQuery.ts
│ └── useShortlist.ts
├── pages/
│ ├── Compare.tsx
│ ├── Home.tsx
│ └── PlayerDetail.tsx
└── App.tsx
```
> درخت بالا دقیقاً همان چیزی است که باید در فایل ارسالی آپلود کنید. پوشهٔ `src/` باید در ریشهٔ فایل زیپ باشد و ساختار داخلش همان ساختار پروژهٔ اولیه بماند.
>
> پوشهٔ `node_modules` را در فایل ارسالی نگذارید و فایل تازهای هم اضافه نکنید.
- **توجه:** فایلها باید در **ریشهٔ** فایل زیپ باشند، نه داخل یک پوشهٔ اضافه. اگر همهچیز را داخل پوشهای به اسم `answer` بگذارید، مسیرهای موردانتظار پیدا نمیشوند و داوری اجرا نمیشود.
- **توجه: فایل تازهای نسازید.** سیستم داوری کوئرا فقط فایلهای بالا را برمیدارد، پس اگر ماژول جدیدی بسازید و از جایی `import`ش کنید، بیلد پروژه هنگام داوری شکست میخورد و سیستم داوری نمره صفر را خواهد داد. کد کمکی را داخل همان فایلهای موجود بنویسید.
- **توجه:** تمام متنهای داخل صفحه **انگلیسی** هستند.
- **توجه:** ارزیابی به رنگ و فونت و فاصله کاری ندارد؛ مقدار بازگشتی توابع، رفتار کامپوننتها و `data-testid`ها بررسی میشوند.
- **توجه:** توابع `engine` باید خالص بمانند و به `DOM` یا `localStorage` دسترسی **نداشته** باشند.