در اولین سوال از سری جدید **مسابقات المپیک فناوری پردیس** جدول برترین گلزنان جام فناوری پردیس را با `HTML` و `CSS` پیادهسازی میکنید. جدول باید نام هر بازیکن، کشورش و تعداد گلهایش را نشان بدهد و در بالای جدول، آقای گل با یک کادر متمایز نمایش داده شود! هدف این سوال آشنا شدن با ساختار پروژهها و نحوهٔ ارسال پاسخ و سیستم داوری کوئرا است، برای همین عمداً ساده نگه داشته شده است. **استفاده از `JavaScript` مجاز نیست.**

**هدف این سوال** پیادهسازی ساختار درست `HTML` و اعمال چند قاعدهٔ پایهٔ `CSS` است. **ظاهر صفحهٔ شما لازم نیست شبیه تصویرهای این صورت سوال باشد.** تصویرها فقط برای این آمدهاند که تصور کنید خروجی چه چیزی است؛ سیستم داوری کوئرا رنگ، فونت، فاصله و اندازهٔ پیکسلی را **اصلاً بررسی نمیکند** و فقط وجود عنصرها و چند ویژگی مشخص را میسنجد. پس وقتتان را صرف ژینگولیشن **نکنید!**
# **پروژهٔ اولیه**
**پروژهٔ اولیه** را از [این لینک](/contest/assignments/103143/download_problem_initial_project/356836/) دانلود کنید.
```plaintext
initial_project/
├── index.html
└── styles.css
```
+ **نکته:** فقط همین دو فایل را تغییر دهید و فایل جدیدی به پروژه اضافه **نکنید.**
+ **نکته:** داخل فایلهای پروژهٔ اولیه، در هر بخش کامنتهایی جهت انجام راهنمایی برای پیادهسازی قرار گرفتهاند: قرارداد هر تابع، حالتهای مرزی و نکتههای پیادهسازی. پیش از شروع هر فایل، کامنتهای بالای آن را بخوانید.
+ **نکته:** تمام متنهای قابل نمایش در صفحه باید انگلیسی باشند. عنصر `html` هم باید `lang="en"` و `dir="ltr"` داشته باشد.
# **ساختار** *HTML* **گلزنان برتر!**
صفحه شامل یک `header`، یک `main` و یک `footer` است و باید دقیقاً یک عنصر `h1` و یک عنصر `main` داشته باشد.
|  |
| :-: |
| درخت عناصر و جای هر `data-testid` در آن |
| **بخش** | `data-testid` | **پیادهسازی** |
| :-: | :-: | :-: |
| هدر | `site-header` | باید عنصر `header` باشد |
| معرفی آقای گل | `golden-boot` | کادر متمایز بالای جدول |
| نام آقای گل | `leader-name` | داخل همان کادر، با متن غیرخالی |
| جدول گلزنان | `leaderboard` | باید عنصر `ol` باشد |
| هر ردیف | `scorer-row` | باید عنصر `li` باشد؛ **دستکم پنج** ردیف لازم است |
| فوتر | `site-footer` | باید عنصر `footer` باشد |
| متن حق نشر | `copyright` | داخل فوتر، با متن غیرخالی |
داخل **هر** ردیف `scorer-row` این سه عنصر باید باشند:
| **عنصر** | `data-testid` | **شرط** |
| :-: | :-: | :-: |
| نام بازیکن | `player-name` | متن غیرخالی |
| کشور | `player-country` | متن غیرخالی |
| تعداد گل | `goals` | متنش باید دستکم یک رقم داشته باشد |
+ **نکته:** جدول باید فهرست مرتب (`ol`) باشد، نه `ul` و نه `table`. رتبهٔ گلزنان یک ترتیب معنادار دارد و `ol` و این ساختار ترتیب موارد را برای `screen reader` نیز مشخص میکند.
+ **نکته:** نام بازیکنها، کشورها و تعداد گلها میتوانند کاملا به دلخواه انتخاب شوند؛ فقط باید در صفحه وجود داشته باشند!
# **پیادهسازی** `head`
داخل `head` این چهار مورد باید وجود داشته باشد:
+ `<meta charset="utf-8">`
+ یک *viewport meta tag* که مقدار `content` آن شامل `width=device-width` باشد
+ یک `title` که **بیش از چهار کاراکتر** داشته باشد
+ یک `<link rel="stylesheet" href="styles.css">`
# **الزامات** *CSS* **گلزنان برتر!**
سه مورد در فایل `styles.css` بررسی میشود:
1. دستکم یک متغیر `CSS` تعریف کنید و جایی با `var(...)` از آن استفاده کنید:
```css style.css css
:root {
--gold: #f5c542;
}
[data-testid="golden-boot"] {
background: var(--gold);
}
```
2. **هر** ردیف `scorer-row` باید `display: flex` داشته باشد.
3. کادر آقای گل باید گوشههای گرد داشته باشد؛ یعنی مقدار `border-radius` آن چیزی غیر از `0px` باشد.

# **آنچه سیستم داوری بررسی خواهد کرد**
در این سوال **نیازی نیست** که ظاهر صفحه **دقیقاً** مثل تصاویر نمونه باشد. سیستم داوری کوئرا رنگها، فونتها، فاصلهها و اندازههای پیکسلی را **بررسی نمیکند.** آنچه بررسی میشود به شرح زیر است:
+ مقدار `lang="en"` و `dir="ltr"` روی عنصر `html`
+ **وجود** چهار مورد گفتهشده در `head`
+ **وجود** دقیقاً یک `h1` و دقیقاً یک `main`
+ **وجود** عناصر `header` و `footer` و `ol` در جاهایی که ذکر شده
+ **وجود** دستکم پنج ردیف `scorer-row`
+ **وجود** سه عنصر داخلی در هر ردیف و غیرخالی بودن متنشان
+ **وجود** رقم در متن تعداد گلها
+ **وجود** تعریف و استفادهٔ متغیر `CSS` در فایل استایل
+ **اعمال** `display: flex` به هر `scorer-row` و اعمال `border-radius` غیرصفر به `golden-boot`
|  |
| :-: |
| نگاشت هر `data-testid` به عنصر متناظرش روی صفحه |
# **آنچه باید آپلود کنید**
- **توجه:** فایل زیپ شما باید دقیقاً شامل این دو فایل باشد:
```plaintext
index.html
styles.css
```
- **توجه:** هر دو باید در **ریشهٔ** فایل زیپ باشند، نه داخل یک پوشهٔ اضافه. اگر مثلاً همهچیز را داخل پوشهای به اسم `answer` بگذارید، مسیرهای موردانتظار پیدا نمیشوند و سیستم داوری **نمره صفر** را برای آن لحاظ خواهد کرد.
- **توجه:** نام فایلها را **تغییر ندهید** و فایل جدیدی اضافه نکنید؛ سیستم داوری کوئرا فقط همین دو نام را بررسی خواهد کرد و سایر فایلهای افزوده شده نادیده گرفته خواهند شد!
گلزنان برتر
**فرشاد** یک عادت خوب و شاید هم بد دارد: هر سایتی را که خوشش بیاید تا آخر اسکرول میکند! حتی وقتی کاری به محتوایش ندارد. او هفتهٔ پیش به سایتی رسید که با اسکرول بیشتر، مسیر روی صفحه نیز بهتدریج رسم میشد و ظاهری شبیه جاده داشت! او پس از چند بار پیمایش صفحه، تصمیم گرفت نمونهٔ مشابهی را پیادهسازی کند.
در این سوال صفحهٔ `landing` **جاده افتخار** را کامل میکنید؛ صفحهای که مسیر رسیدن تیمها به **فینال جام جهانی پردیس** را در طول اسکرول روایت میکند. **استفاده از `JavaScript` در این سوال مجاز نیست.**
|  |
| :-: |
**هدف این سوال** پیادهسازی انیمیشنهای وابسته به اسکرول (`scroll-driven animations`)، پیمایش صفحه (`scroll snap` و لنگرها و هدر چسبان)، تب بدون `JavaScript` و ساختار معنایی و دسترسپذیری است. **همچنین تمام طراحیهای بصری از قبل پیادهسازی شده و در قالب پروژه اولیه در اختیار شما قرار گرفتهاند.**
# **پروژهٔ اولیه**
پروژهٔ اولیه را از [این لینک](/contest/assignments/103143/download_problem_initial_project/356838/) دانلود کنید.
```plaintext
initial_project/
├── index.html
└── styles.css
```
+ **نکته:** داخل فایلهای پروژهٔ اولیه، در هر بخش کامنتهایی جهت انجام راهنمایی برای پیادهسازی قرار گرفتهاند: قرارداد هر تابع، حالتهای مرزی و نکتههای پیادهسازی. پیش از شروع هر فایل، کامنتهای بالای آن را بخوانید.
+ **نکته:** پروژهٔ اولیه یک صفحهٔ **کامل ولی کاملاً ثابت** است. اگر همین حالا `index.html` را در مرورگر باز کنید، تمام بخشها، متنها، جدولها، کارت شهرها، کاشیهای آمار و هر دو تصویر `SVG` (مسیر جاده و جام) را سر جایشان میبینید. هیچکدام از اینها را دوباره نسازید و طراحی را **تغییر ندهید!**
+ **نکته:** آنچه عمداً حذف شده همان چیزی است که در این سوال از شما خواسته میشود: صفحه **هیچ حرکتی ندارد،** ساختارش از `div` ساخته شده و شناسههای اصلی و ویژگیهای دسترسپذیری در آن **موجود نیست.** جای هر مورد با کامنت `TODO` در همان فایل مشخص شده است.
## **آنچه باید اضافه کنید**
پیادهسازی شما در این سوال به سه دسته تقسیم میشود و سیستم داوری کوئرا هم فقط همین سه دسته را میسنجد:
| **دسته** | **چه کاری** |
| :-: | :-: |
| ساختار معنایی | `div`های اسکلت را به `header` و `nav` و `main` و `section` و `footer` تبدیل کنید و عنوانها را از `p` به `h1` و `h2` ببرید |
| شناسه و دسترسپذیری | مقدار `data-testid` عنصرهای اصلی و ویژگیهای `aria-label` و `aria-labelledby` و `aria-hidden` را بگذارید |
| حرکت و تعامل | انیمیشنهای وابسته به اسکرول، `scroll snap`، هدر چسبان، نوار متحرک، جابهجایی تبها و حالت کاهش حرکت |
+ **نکته:** شناسههای تکرارشونده از قبل در فایل پروژه اولیه قرار گرفتهاند تا وقتتان را صرف تایپ نکنید. دوازده چیپ تیمها، سه نوار شهر و لایههای پارالاکسشان، پنج کاشی آمار و مقدارهایشان، هفت نقطهٔ نوار پیشرفت، سه پنل و سه رادیو و سه برچسب تب، همگی `data-testid` دارند. شما فقط شناسهٔ عنصرهای اصلی را اضافه میکنید که در جدولهای متن سوال ذکر شدهاند.
+ **نکته:** تمام `id`های لازم برای لنگرها از قبل روی عنصرها هستند، پس لینکهای منو و نوار پیشرفت از همان ابتدا به بخش درست میروند. `id`ها را عوض نکنید.
+ **نکته:** تمام متنهای قابل نمایش در صفحه باید انگلیسی باشند و عنصر `html` باید `lang="en"` داشته باشد. این دو مورد از قبل در پروژهٔ اولیه تنظیم شدهاند.
# **ساختار کلی صفحه**
**جاده افتخار فرشاد** شامل یک `header`، یک نوار پیشرفت ثابت، هفت بخش اصلی و یک `footer` خواهد بود. هفت بخش اصلی باید داخل عنصر `main` قرار بگیرند و آن عنصر `data-testid="journey"` بگیرد.
|  |
| :-: |
| کشیده شدن مسیر SVG همراه با اسکرول؛ همین جریان را در اجرای واقعی برنامه نشان میدهد |
هر هفت بخش با محتوای کاملشان در **فایل پروژهٔ اولیه** قرار گرفتند و `id` هر کدام هم از قبل در فایل موجود است، پس لینکهای منو و نوار پیشرفت از همان ابتدا کار میکنند. کاری که با این جدول میکنید این است که به هر بخش `data-testid` متناظرش را بدهید و `div` آن را به `section` تبدیل کنید.
| **بخش** | `data-testid` |
| :-: | :-: |
| شروع (شامل تنها `h1` صفحه با `data-testid="hero-title"`) | `chapter-hero` |
| مرحلهٔ گروهی | `chapter-group-stage` |
| مرحلهٔ حذفی | `chapter-knockouts` |
| شهرهای میزبان | `chapter-cities` |
| تیمهای صعودکرده | `chapter-nations` |
| آمار | `chapter-stats` |
| فینال | `chapter-final` |
|  |
| :-: |
| ترتیب اجرا از ورودی کاربر تا بهروزرسانی صفحه |
# **پیادهسازی الزامات** *HTML*
## **پیادهسازی** `header` **و منو**
هدر با لوگو، پنج لینک و دکمهٔ اصلی از قبل ساخته شده است و مقدار `href` همهٔ لینکها هم درست است. پیادهسازی سه مورد روی آن باقیمانده است: `div` بیرونی را به `header` و `div` داخلی را به `nav` تبدیل کنید، `data-testid`ها و `aria-label` را بگذارید و در `CSS` هدر را چسبان کنید. برای چسبیدن، `position: sticky` بهتنهایی کافی نیست و کنارش `top: 0` هم لازم است!
| **عنصر** | `data-testid` | **نوع** | **شرط** |
| :-: | :-: | :-: | :-: |
| لوگو | `nav-brand` | `a` | مقدار `href` برابر `#top` |
| فهرست لینکها | `nav-links` | `ul` یا `ol` | شامل پنج لینک زیر |
| لینک اصلی | `nav-cta` | `a` | باید دیده شود و `href` داشته باشد |
- پنج لینک فهرست باید این `data-testid`ها را بگیرند: `nav-link-group-stage`، `nav-link-knockouts`، `nav-link-cities`، `nav-link-nations` و `nav-link-final`. مقدار `href` هرکدام از قبل به بخش درست اشاره میکند.
برای حالت شیشهای هدر از `backdrop-filter: blur(...)` استفاده کنید. برای اینکه این افکت دیده شود، **پسزمینهٔ هدر باید نیمهشفاف باشد.**
## **رسم تدریجی مسیر** `SVG` **با اسکرول**
**خودِ تصویر `SVG` از قبل کشیده شده است.** هر دو مسیر، چهار نقطهٔ روی جاده و گرادیانها داخل `index.html` هستند و لازم نیست چیزی طراحی کنید؛ کار شما فقط **به حرکت درآوردن** همین مسیر آماده است.
|  |
| :-: |
| شروع مسیر `SVG` در ابتدای صفحه |
روی مسیر آماده مقدار `pathLength="1"` گذاشته شده است تا لازم نباشد طول واقعی مسیر را حساب کنید؛ پس `stroke-dasharray: 1` و بردن `stroke-dashoffset` از `1` تا `0` کافی است. آنچه باید به این بخش اضافه کنید:
+ روی عنصر `svg` مقدار `data-testid="journey-path"` و یک `aria-label` بگذارید. عنصر `viewBox` و `title` داخلش از قبل هست.
+ روی مسیر متحرک مقدار `data-testid="path-fill"` بگذارید. مسیر ثابت پسزمینه از قبل کلاس `path-track` دارد.
+ عنوان این بخش را به `h2` تبدیل کنید و `data-testid="path-title"` بگیرد.
+ انیمیشن رسم مسیر را در `styles.css` بنویسید.
+ **نکته:** `path-title` باید `h2` باشد، چون تنها `h1` صفحه `hero-title` است.
انیمیشن رسم مسیر باید با `animation-timeline: view()` انجام شود و **دقیقاً به همین شکل، بدون آرگومان** نوشته شود. `view()` یک خط زمان بر پایهٔ میزان دیدهشدن عنصر در ناحیهٔ اسکرول میسازد؛ پیشرفت انیمیشن به **موقعیت عنصر نسبت** به *viewport* وابسته میشود.
اگر میخواهید بازهٔ اجرای انیمیشن را تنظیم کنید، از خاصیت جداگانهٔ `animation-range` استفاده کنید، نه از آرگومانهای داخل `view()`. مثلاً `animation-range: cover 5% cover 70%` **یعنی انیمیشن از وقتی ۵٪ مسیر پیموده شد شروع و در ۷۰٪ تمام شود!**
+ **نکته:** نوشتن `animation` به شکل خلاصه، مقدار `animation-timeline` را به حالت پیشفرض برمیگرداند. پس اول `animation` را بنویسید و بعد `animation-timeline` را، نه برعکس:
```css style.css css
[data-testid="path-fill"] {
animation: draw linear both;
animation-timeline: view();
animation-range: cover 5% cover 70%;
}
```
## **پیادهسازی بخشهای مختلف و** `scroll snap`
هفت بخش در جاده افتخار پشت سر هم میآیند و اسکرول میتواند روی هر بخش `snap` شود. ویژگی `scroll-snap-type` باید روی عنصر اسکرولکنندهٔ صفحه، یعنی `html`، اعمال شود و `scroll-snap-align` روی هر بخش:
```css style.css css
html {
scroll-behavior: smooth;
scroll-snap-type: y proximity;
}
[data-testid^="chapter-"] {
scroll-snap-align: start;
min-height: 100vh;
}
```
+ **نکته:** در این سوال اسکرولکنندهٔ اصلی صفحه `html` است، پس `scroll-snap-type` را روی `html` بگذارید، نه روی `main`.
+ **نکته:** از مقدار `proximity` بهجای `mandatory` استفاده کنید. با `proximity` مرورگر فقط وقتی به نقطهٔ *snap* نزدیک باشید آن را اعمال میکند ولی `mandatory` پیمایش را **سختگیرانهتر** میکند و کاربر را بین نقاط *snap* جابهجا میکند.
سایر شرطهایی که در پیادهسازی این بخش باید در نظر بگیرید:
+ بخشهای `chapter-group-stage` و `chapter-final` باید `aria-labelledby` داشته باشند که به `id` عنوان خودشان اشاره کند. مثلاً `<h2 id="group-stage-title">` و `<section aria-labelledby="group-stage-title">`.
+ شمارهگذاری: عنصرهای شماره با متن `01` و `02` و شمارهٔ بخش پایانی از قبل در صفحه هستند؛ فقط `data-testid`های `chapter-index-1` و `chapter-index-2` و `chapter-index-final` را روی همانها بگذارید. شمارهگذاری بخشهای میانی سنجیده نمیشود.
+ دو عنصر آمادهٔ داخل دو بخش اول را با `data-testid`های `reveal-group-stage` و `reveal-knockouts` نشان کنید و برایشان انیمیشن ظاهرشدن هنگام ورود به `viewport` بنویسید.
## **پیادهسازی شهرهای میزبان و افکت پارالاکس**
سه نوار شهر با نامشان و لایهٔ پارالاکسشان از قبل ساخته شدهاند و `data-testid` هر شش عنصر هم روی آنهاست. کاری که میماند این است که لایهٔ پارالاکس هر شهر را **به حرکت دربیاورید** تا با سرعتی متفاوت از اسکرول صفحه جابهجا شود.
| **شهر** | **نوار** | **لایهٔ پارالاکس** |
| :-: | :-: | :-: |
| **نیویورک** | `city-band-newyork` | `city-parallax-newyork` |
| **تورنتو** | `city-band-toronto` | `city-parallax-toronto` |
| **مکزیکوسیتی** | `city-band-mexico` | `city-parallax-mexico` |
+ **لایههای پارالاکس تزئینی هستند** و محتوای مستقلی برای فناوریهای کمکی ندارند، پس روی آنها `aria-hidden="true"` بگذارید.
+ عنوان آمادهٔ بالای این بخش را به `h2` تبدیل کنید و `data-testid="cities-title"` بگیرد.
+ حرکت هر لایه باید با خط زمان اسکرول ساخته شود، نه با یک انیمیشن همیشگی.
|  |
| :-: |
| سه نوار شهر که لایهشان با سرعت متفاوت حرکت میکند |
## **پیادهسازی نوار متحرک تیمهای صعودکرده**
+ عنصر بیرونی با `data-testid="nations-marquee"` که دارای `aria-label` باشد.
+ فهرست دوازده تیم از قبل به شکل `ul` در صفحه است و `data-testid` چیپها هم رویشان هست؛ فقط `data-testid="marquee-track"` را به خود فهرست بدهید.
+ عنوان آمادهٔ این بخش را به `h2` تبدیل کنید و `data-testid="nations-title"` بگیرد.
+ نام انیمیشن این نوار باید `marquee` باشد و مقدار `animation-name` روی `marquee-track` نباید `none` باشد.
+ برای اینکه لبههای ابتدا و انتهای نوار بهجای قطع شدن، محو شوند، از `mask-image` استفاده کنید.
|  |
| :-: |
| نواحی اصلی صفحه و نام هرکدام |
## **پیادهسازی جدولهای گروهی با** `input` **از نوع** `radio`
سه جدول گروهی با محتوایشان، سه `input` از نوع `radio` و سه `label` از قبل در صفحه هستند و به هم وصل شدهاند. چیزی که کار نمیکند **جابهجایی** است: در پروژهٔ اولیه همیشه پنل اول دیده میشود. کار شما این است که نمایش و مخفیسازی پنلها را با سلکتور `:checked` بنویسید تا کلیک روی هر برچسب، جدول متناظرش را بیاورد، آن هم بدون حتی یک خط `JavaScript`!
|  |
| :-: |
| جابهجایی جدولها فقط با `input` رادیویی و `:checked` |
این بخش باید **داخل `chapter-group-stage`** باشد:
| **عنصر** | `data-testid` | **شرط** |
| :-: | :-: | :-: |
| عنصر دربرگیرنده | `group-tabs` | باید `aria-label` داشته باشد |
| نوار برچسبها | `tab-strip` | شرط خاصی ندارد؛ فقط باید وجود داشته باشد |
| سه `input[type="radio"]` | `tab-radio-a` تا `tab-radio-c` | یک مقدار `name` مشترک و غیرخالی؛ اولی از ابتدا `checked` |
| سه `label` | `tab-label-a` تا `tab-label-c` | مقدار `for` برابر `id` عنصر `radio` متناظر |
| سه پنل | `panel-group-a` تا `panel-group-c` | فقط پنل فعال باید قابل مشاهده باشد |
| سه جدول | `table-group-a` تا `table-group-c` | هرکدام یک `thead` با **دستکم سه** عنصر `th` |
+ جدول `table-group-a` باید دقیقاً چهار عنصر `tr` در `tbody` داشته باشد و یکی از تیمهایش `Iran` باشد.
+ پنلهای غیرفعال نباید قابل مشاهده یا قابل تعامل باشند. میتوانید از `display: none` یا `visibility: hidden` استفاده کنید. فقط کم کردن `opacity` کافی نیست، چون عنصر با `opacity: 0` هنوز از نظر سیستم داوری قابل مشاهده خواهد بود.
+ استایل برچسب فعال باید با سلکتور `:checked` نوشته شود، نه با یک کلاس دستی.
## **آمار و جام**
آمارها داخل عنصری با `data-testid="bento-grid"` قرار میگیرند که در `CSS` مقدار `display: grid` میگیرد. پنج کاشی دارد:
`stat-tile-matches`، `stat-tile-nations`، `stat-tile-cities`، `stat-tile-goals` و `stat-tile-days`
+ پنج کاشی آمار با عددهایشان و چیدمان `grid` از قبل آمادهاند و `data-testid` خودشان را دارند. عدد `104` هم بهصورت **متن واقعی داخل `HTML`** نوشته شده و باید همانجا بماند؛ اگر بعداً با `counter()` کار کردید، متن اصلی را از `DOM` برندارید چون سیستم داوری متن را از `DOM` میخواند.
+ عنوان آمادهٔ این بخش را به `h2` تبدیل کنید و `data-testid="stats-title"` بگیرد.
+ کاری که در این بخش میماند، شمارش تدریجی عددهاست.
|  |
| :-: |
| کاشیهای آمار با شمارش تدریجی اعداد |
متغیرهای معمولی `CSS` نوع دادهٔ مشخصی ندارند و مرورگر نمیتواند مقدارشان را بهتدریج تغییر بدهد، پس نمیشود مستقیماً با آنها عدد را شمارشی بالا برد. با `@property` نوع متغیر را ثبت کنید و آنوقت مرورگر میتواند مقدارش را از عددی به عدد دیگر ببرد. سپس همان مقدار را با `counter()` نمایش دهید:
```css style.css css
@property --num {
syntax: "<integer>";
inherits: false;
initial-value: 0;
}
```
در قسمت آخر یک جام قرار دارد. **خود تصویر جام از قبل با `SVG` کشیده شده است** و لازم نیست چیزی طراحی کنید:
+ روی عنصر `svg` جام مقدار `data-testid="trophy-svg"` را بگذارید؛ عنصر `title` داخلش از قبل هست.
+ برای همین `svg` انیمیشن ورود بنویسید تا با رسیدن به بخش پایانی ظاهر شود؛ مقدار `animation-name` آن نباید `none` بماند.
## **پیادهسازی نوار پیشرفت کنار صفحه**
+ عنصر با `data-testid="progress-rail"` که باید `nav` باشد، `aria-label` داشته باشد و در `CSS` مقدار `position: fixed` بگیرد.
+ داخل آن عنصری با `data-testid="rail-dots"` که **دقیقاً هفت** عنصر `a` دارد، یکی برای هر بخش: `rail-dot-hero`، `rail-dot-group-stage`، `rail-dot-knockouts`، `rail-dot-cities`، `rail-dot-nations`، `rail-dot-stats` و `rail-dot-final`. هرکدام `href` دارند که به `id` بخش متناظر اشاره میکند و هرکدام یا متن دارند یا `aria-label`.
+ دو عنصر روی هم: `rail-track` که مسیر خالی است و `rail-fill` که پر میشود. هر دو تزئینیاند و باید `aria-hidden="true"` بگیرند.
پیشرفت نوار باید به اسکرول کل صفحه متصل باشد و نباید به ورود یک عنصر به `viewport` وابسته باشد. پس اینجا از `scroll()` استفاده کنید و تغییر ارتفاع را با `scaleY` انجام دهید:
```css style.css css
[data-testid="rail-fill"] {
transform-origin: top;
animation: fill linear both;
animation-timeline: scroll(root block);
}
```
## **پیادهسازی** `footer` **و حالت کاهش حرکت**
محتوای فوتر از قبل نوشته شده است؛ `div` بیرونی را به `footer` و `div` منوی داخلش را به `nav` تبدیل کنید و این `data-testid`ها را بگذارید:
+ `footer-brand` روی عنصری که متن `Road to Glory` را دارد.
+ `footer-nav` روی منوی فوتر، که `aria-label` هم میخواهد.
+ `footer-copy` روی عنصری که عدد `2026` را دارد.
+ `back-to-top` روی لینک بازگشت به بالا؛ مقدار `href` آن از قبل `#top` است.
در `CSS` یک مورد باقی میماند: یک بلوک `@media (prefers-reduced-motion: reduce)`.
برخی کاربران به دلیل حساسیت به حرکت، انیمیشن را در سیستمعاملشان غیرفعال میکنند. در صورت فعال بودن این تنظیم، انیمیشنهای تزئینی نباید اجرا شوند. **داخل همین بلوک باید برای `rail-fill` هم قانون بنویسید** و آن را در حالت پرشدهٔ نهایی ثابت کنید تا کاربر همچنان نوار پیشرفت را ببیند.
# **آنچه سیستم داوری بررسی میکند**
سیستم داوری کوئرا رنگها، فونتها، فاصلهها و اندازههای پیکسلی را بررسی نمیکند. ظاهر صفحه لازم نیست دقیقاً مثل تصاویر نمونه باشد. آنچه سیستم داوری کوئرا بررسی میکند به شرح زیر است:
+ **وجود عناصر** با `data-testid`های گفتهشده و نوعشان در مواردی که ذکر شده
+ **تعداد عناصر** در مواردی که ذکر شده (هفت نقطهٔ نوار، پنج کاشی، دستکم دوازده `li`، چهار `tr`)
+ **مقادیر** `href` و ویژگیهای `aria`
+ چند ویژگی محاسبهشدهٔ `CSS`: `position`، `overflow`، `scroll-snap-align`، `animation-name`
+ **رفتار** برچسبهای گروه پس از کلیک
+ در **بلوک** `prefers-reduced-motion` باید نام `rail-fill` هم بیاید. انیمیشنهای تزئینی را متوقف کنید ولی نوار پیشرفت را در حالت پرشدهٔ نهایی نگه دارید تا اطلاعات پیشرفت همچنان در دسترس بماند.
# **آنچه باید آپلود کنید**
- **توجه:** فایل زیپ شما باید دقیقاً شامل این دو فایل باشد:
```plaintext
index.html
styles.css
```
- **توجه:** هر دو باید در **ریشهٔ** فایل زیپ باشند، نه داخل یک پوشهٔ اضافه. اگر مثلاً همهچیز را داخل پوشهای به اسم `answer` بگذارید، مسیرهای موردانتظار پیدا نمیشوند و داوری اجرا نمیشود.
- **توجه:** نام فایلها را **تغییر ندهید** و فایل سومی اضافه نکنید؛ سیستم داوری کوئرا فقط همین دو فایل را بررسی خواهد کرد.
- **توجه:** انیمیشنهای وابسته به اسکرول یعنی `animation-timeline` و `view()` و `scroll()` هنوز در همهٔ مرورگرها یکسان پشتیبانی نمیشوند. `@property` وضعیت بهتری دارد و در نسخههای جدید همهٔ مرورگرهای اصلی در دسترس است. ارزیابی در سیستم داوری روی `Chromium` انجام میشود، پس پیش از ارسال پاسخ را در `Chrome` اجرا و آزمایش کنید.
جادهٔ افتخار فرشاد!
وقتی پخش، **زنده** است و بازپخشی در کار نیست، **لحظهای که از دست بدهید از دست رفته است!** چیزی که کم است یک پخشکنندهٔ ساده نیست، بلکه ابزاری است برای برگشتن: عقب و جلو رفتن روی خط زمان، پخش آهسته، تکرار یک بازهٔ کوچک و پریدن مستقیم به لحظههای مهم بازی! در این سوال باید همین پخشکننده را زنده کنید.

**هدف این سوال** پیادهسازی فقط و فقط **منطق و رفتار برنامه** با `JavaScript` است. **کل ظاهر برنامه از قبل ساخته شده است**؛ اگر همین حالا `index.html` پروژهٔ اولیه را در مرورگر باز کنید، دقیقاً همان صفحهای را میبینید که در تصویرهای این صورت سوال آمده است. هیچ `HTML` و `CSS` جدیدی ننویسید و ظاهر را تغییر ندهید؛ سیستم داوری کوئرا در این سوال **اصلاً ظاهر را بررسی نمیکند** و فقط رفتار پخشکننده را میسنجد.
# **پروژهٔ اولیه**
برای دانلود پروژهٔ اولیه روی [این لینک](/contest/assignments/103143/download_problem_initial_project/356839/) کلیک کنید.
<details class="green">
<summary>**ساختار فایلها و پروژه اولیه**</summary>
```plaintext
initial_project/
├─ <mark class="green" title="آماده است؛ تغییرش ندهید">index.html</mark>
├─ <mark class="green" title="آماده است؛ تغییرش ندهید">styles.css</mark>
└─ modules/
├─ <mark class="green" title="آماده است؛ تغییرش ندهید">data.js</mark>
├─ <mark class="orange" title="این فایل را تکمیل کنید">format.js</mark>
├─ <mark class="orange" title="این فایل را تکمیل کنید">track.js</mark>
├─ <mark class="orange" title="این فایل را تکمیل کنید">playhead.js</mark>
├─ <mark class="orange" title="این فایل را تکمیل کنید">chapters.js</mark>
├─ <mark class="orange" title="این فایل را تکمیل کنید">trim.js</mark>
├─ <mark class="orange" title="این فایل را تکمیل کنید">speed.js</mark>
├─ <mark class="orange" title="این فایل را تکمیل کنید">clock.js</mark>
├─ <mark class="orange" title="این فایل را تکمیل کنید">loop.js</mark>
├─ <mark class="orange" title="این فایل را تکمیل کنید">filmstrip.js</mark>
└─ <mark class="orange" title="این فایل را تکمیل کنید">app.js</mark>
```
+ **نکته:** داخل فایلهای پروژهٔ اولیه، در هر بخش کامنتهایی جهت انجام راهنمایی برای پیادهسازی قرار گرفتهاند: قرارداد هر تابع، حالتهای مرزی و نکتههای پیادهسازی. پیش از شروع هر فایل، کامنتهای بالای آن را بخوانید.
+ **نکته:** فایلهای سبز آمادهاند و **نباید تغییرشان بدهید**: `index.html` کل نشانهگذاری صفحه با همهٔ `data-testid`ها، `styles.css` کل طراحی و `data.js` دادهٔ ثابت فیلم. فایلهای نارنجی همانهاییاند که باید تکمیلشان کنید و همگی منطق اصلی این سوال هستند.
+ **نکته:** فایل `app.js` تنها جایی است که اجازه دارد به `DOM` دست بزند و بقیهٔ ماژولها باید **کاملاً مستقل از صفحه بمانند.** در `app.js` هم نشانی تکتک عنصرهای صفحه از قبل در شیء `el` پیدا شده است، پس هیچوقت لازم نیست دنبال سلکتور بگردید؛ فقط منطق را مینویسید. نام فایلها و نام `export`ها را **تغییر ندهید،** چون سیستم داوری همین مسیرها را مستقیم `import` میکند.
</details>
<details class="red">
<summary>**هشدار: هیچ فایلی را حذف نکنید**</summary>
سیستم داوری این نُه ماژول را مستقیم `import` میکند: `clock` و `track` و `playhead` و `chapters` و `loop` و `trim` و `speed` و `filmstrip` و `format`. اگر حتی یکی از آنها نباشد، سیستم داوری **نمرهٔ صفر را لحاظ خواهد کرد**. فایل `app.js` را خود صفحه بارگذاری میکند و بدون آن، تستهای مربوط به رفتار رابط کاربری نمرهای دریافت نخواهند کرد.
</details>
# **جزئیات پیادهسازی**
در این سوال، یک پخشکنندهٔ ویدیو میسازید که **ویدیو ندارد!** بهجای فیلم واقعی، فقط یک خط زمان با چند کلیپ دارید و همهٔ قابلیتهای یک پخشکنندهٔ ویدیو را روی همین خط زمان پیاده میکنید.
|  |
| :-: |
| جابهجایی روی خط زمان و تغییر کلیپ فعال؛ همین جریان را در اجرای واقعی برنامه نشان میدهد |
+ **نکته:** منطق در ده ماژول جدا نوشته میشود و **هیچکدام نباید به `DOM` یا `document` دسترسی داشته باشند**. فقط `app.js` با `document` کار دارد.
+ **نکته:** هیچ تابعی نباید آرایه یا شیء ورودیاش را تغییر بدهد. هر تابعی که وضعیت تازهای میسازد، باید نسخهٔ تازهای از همان ساختار برگرداند؛ توابعی مثل قالببندی زمان فقط یک مقدار ساده برمیگردانند.
<details class="green">
<summary>**نکته: شکل دادهها را قبل از شروع بخوانید**</summary>
چهار ساختار زیر در کل برنامه دستبهدست میشوند:
```js
// clip
{ id: "opening-goal", label: "Opening Goal", kind: "goal", in: 19, out: 30 }
// track
{ clips: [ /* sorted by in-time */ ] }
// playhead
{ time: 0, duration: 40 }
// chapter
{ id: "a", time: 10, label: "B", index: 1 }
// loop
{ a: 5, b: 12 }
```
> **کلیپ** یک تکه از فیلم است؛ `id` شناسهٔ یکتا، `label` متن نمایشی، `kind` نوع رویداد و `in` و `out` ابتدا و انتهای آن بر حسب ثانیه. کلیپها فاصله ندارند، یعنی `out` هر کلیپ برابر `in` کلیپ بعدی است. **`track`** فقط آرایهای از کلیپهاست که همیشه بر اساس زمان شروع مرتب نگه داشته میشود؛ توابع `track.js` فرض میکنند این ترتیب برقرار است، پس هر تابعی که کلیپ اضافه یا جابهجا میکند باید ترتیب را دوباره برقرار کند.
>
> **نشانگر پخش** نقطهای است که هماکنون روی خط زمان روی آن ایستادهاید و طول کل فیلم را هم با خودش دارد؛ `time` هیچوقت نباید از `0` کمتر یا از `duration` بیشتر شود. **فصل** نشانگری روی خط زمان است که `time` آن لحظهٔ هدف و `index` جایگاهش در فهرست مرتبشده است. **لوپ** هم بازهای است که پخش داخل آن تکرار میشود؛ تا وقتی هر دو نقطه گذاشته نشده باشند مقدارشان `null` است و لوپ هیچ اثری روی پخش ندارد. مقدار `null` اینجا با `0` فرق دارد، چون `0` یعنی نقطه روی ثانیهٔ صفر گذاشته شده است.
</details>
<details class="green">
<summary>**بهتر است پیادهسازی را از کجا شروع کنیم؟**</summary>
1. `format.js` و `data.js` مختصر هستند و بقیه به آنها نیاز دارند.
2. `track.js` و `playhead.js` مدلهای اصلی هستند.
3. `chapters.js` و `trim.js` و `speed.js` و `loop.js` هرکدام مستقل از همدیگر هستند.
4. `clock.js` و `filmstrip.js` پیادهسازی مختصری دارند.
5. و آخر از همه به سراغ `app.js` بروید تا همهچیز را به صفحهٔ آماده وصل کنید!
</details>
<details class="blue">
<summary>**پیادهسازی فایل `data.js` (کلیپهای فیلم)**</summary>
**این فایل هیچ منطقی ندارد** و فقط دادهٔ ثابت برنامه را نگه میدارد. فیلم بازی از هشت کلیپ پشت سر هم ساخته شده که روی یک خط زمان ۶۴ ثانیهای بدون فاصله کنار هم نشستهاند: هر کلیپ از همانجایی شروع میشود که کلیپ قبلی تمام شده است.
هر کلیپ پنج فیلد دارد. مقدار `id` شناسهٔ یکتای کلیپ است و در `data-testid`ها هم استفاده میشود، `label` متنی است که روی صفحه نمایش داده میشود، `kind` نوع رویداد را مشخص میکند (برای رنگ و آیکون به کار میآید) و `in` و `out` لحظهٔ شروع و پایان کلیپ روی خط زماناند، بر حسب ثانیه.
**دقیقاً همین فهرست را با همین ترتیب و همین مقادیر تعریف کنید:**
```js modules/data.js js
export const REEL = [
{ id: "kickoff", label: "Kickoff", kind: "start", in: 0, out: 6 },
{ id: "first-chance", label: "First Chance", kind: "shot", in: 6, out: 14 },
{ id: "yellow-card", label: "Yellow Card", kind: "card", in: 14, out: 19 },
{ id: "opening-goal", label: "Opening Goal", kind: "goal", in: 19, out: 30 },
{ id: "near-equalizer", label: "Near Equalizer", kind: "shot", in: 30, out: 38 },
{ id: "great-save", label: "Great Save", kind: "save", in: 38, out: 46 },
{ id: "second-goal", label: "Second Goal", kind: "goal", in: 46, out: 58 },
{ id: "final-whistle", label: "Final Whistle", kind: "end", in: 58, out: 64 },
];
export const STORAGE_KEY = "road-highlight-replay";
```
> ترتیب `REEL` اهمیت دارد، چون بقیهٔ ماژولها فرض میکنند کلیپها از قبل بر اساس زمان مرتباند. مقدار `out` هر کلیپ برابر `in` کلیپ بعدی است، پس خط زمان حفره ندارد و طول کل فیلم `64` ثانیه است. ثابت `STORAGE_KEY` هم کلیدی است که وضعیت پخش زیر آن در `localStorage` ذخیره میشود.
>
> **این مقادیر را به هیچ عنوان تغییر ندهید.** سیستم داوری روی همین هشت شناسه و همین زمانها کد شما را تست میکند و انتظار دارد `opening-goal` دقیقاً از ثانیهٔ `19` تا `30` باشد؛ جابهجا یا حذف کردن یک کلیپ، تستهای فصلها و نوار `filmstrip` را هم صفر میکند.
</details>
<details class="green">
<summary>**پیادهسازی فایل `format.js` (زمان و درصد)**</summary>
**این ماژول کوچکترین ماژول برنامه است** ولی بقیه بیشتر از همه به آن تکیه میکنند، چون دو کار پرتکرار را در یک جا جمع میکند: **تبدیل عدد به متنی** که روی صفحه دیده میشود و **تبدیل زمان به نسبت و برعکس** تا بشود موقعیتها را روی نوار زمان حساب کرد. هیچکدام از این توابع وضعیتی نگه نمیدارند و هیچکدام به `DOM` دست نمیزنند؛ ورودی میگیرند و خروجی میدهند. در قسمت زیر تعاریف و نحوه پیادهسازی هر کدام از توابع این ماژول بررسی میشوند:
- **تابع `clamp(value, min, max)`:** مقدار را داخل بازهٔ بسته نگه میدارد و عدد برمیگرداند. خودِ دو مرز مجازند، پس `clamp(10, 0, 10)` همان `10` است. یک حالت مرزی دارد که تستها میسنجند: اگر ورودی عدد نباشد، باید `min` برگردد نه `NaN`. این تابع در چند ماژول دیگر هم استفاده میشود، پس خطا در آن جای دیگری خودش را نشان میدهد.
- **تابع `formatTime(seconds)`:** ثانیه را به قالب `mm:ss.cs` تبدیل میکند، یعنی دقیقه و ثانیه هرکدام **دو رقمی با صفر ابتدایی**، بعد یک نقطه و بعد صدم ثانیه که آن هم دو رقمی است. این رشته زیر خط زمان نمایش داده میشود و تستها آن را کاراکتربهکاراکتر میسنجند، پس ورودی `9` باید `"00:09.00"` بدهد و نه `"0:9.0"`.
- **تابع `formatSpeed(multiplier)`** ضریب سرعت را برای نمایش زیر کنترل سرعت آماده میکند و یک `x` به انتهایش میچسباند. صفر اضافه نمیگذارد، یعنی برای ضریب `1` خروجی `"1x"` است نه `"1.0x"`.
- **توابع `timeToFraction(time, duration)` و `fractionToTime(fraction, duration)`** این دو قرینهٔ هماند و کارشان تبدیل میان زمان و موقعیت نسبی روی نوار است. اولی زمان را به نسبتی بین `0` و `1` تبدیل میکند و دومی همان نسبت را به زمان برمیگرداند. خروجی هر دو **عدد** است، نه رشته. حالت مرزی مهمشان تقسیم بر صفر است: وقتی `duration` صفر باشد، `timeToFraction` باید `0` بدهد نه `NaN` و نه `Infinity`.
- **تابع `percent(fraction)`** نسبتی بین `0` و `1` را به رشتهٔ درصد تبدیل میکند. خروجی مستقیم داخل `style.width` یک عنصر مینشیند، پس حتماً باید رشتهای مثل `"25%"` باشد و نه عدد؛ اگر عدد برگردانید، نوار پیشرفت روی صفحه اصلاً رشد نمیکند.
```js modules/format.js js
clamp(-5, 0, 10) // 0
clamp(99, 0, 10) // 10
clamp(4, 0, 10) // 4
formatTime(0) // "00:00.00"
formatTime(9) // "00:09.00"
formatTime(75) // "01:15.00"
formatTime(75.5) // "01:15.50"
formatTime(3.07) // "00:03.07"
timeToFraction(20, 40) // 0.5
timeToFraction(20, 0) // 0
fractionToTime(0.25, 40) // 10
formatSpeed(0.5) // "0.5x"
percent(0.25) // "25%"
```
> به صفرهای ابتدایی `formatTime` دقت کنید: ورودی `9` باید `"00:09.00"` بدهد نه `"0:9.0"` و ورودی `3.07` باید `"00:03.07"` بدهد یعنی صدم ثانیه هم دو رقمی است. خط `timeToFraction(20, 0)` هم حالت تقسیم بر صفر را نشان میدهد که باید `0` بدهد نه `NaN`. این توابع پایهٔ بقیهٔ برنامهاند، پس یک خطای کوچک اینجا در چند بخش دیگر هم خودش را نشان میدهد.
</details>
<details class="violet">
<summary>**پیادهسازی فایل `track.js` (مدل کلیپها)**</summary>
**این ماژول مدل دادهٔ پخشکننده است** فهرست کلیپها و هر پرسشی که دربارهٔ آن پیش میآید قرار است از این ماژول انجام شود. بقیهٔ ماژولها مستقیم سراغ آرایهٔ کلیپها **نمیروند** و همیشه از همین توابع میپرسند **«الان کدام کلیپ پخش میشود؟»** یا **«طول کل چقدر است؟».** هیچکدام از این توابع `track` ورودی را تغییر نمیدهند و هر تغییری یک `track` تازه میسازد. در قسمت زیر تعاریف و نحوه پیادهسازی هر کدام از توابع این ماژول بررسی میشوند:
- **تابع `makeClip(spec)`:** توصیف خام یک کلیپ را به کلیپ استاندارد تبدیل میکند، یعنی شیئی با پنج فیلد `id` و `label` و `kind` و `in` و `out`. کارش پر کردن جاهای خالی است: نبودِ `label` با `id` جبران میشود و نبودِ `kind` با مقدار پیشفرض `"play"`. یک تضمین هم میدهد که بقیهٔ ماژولها به آن تکیه میکنند: `out` هیچوقت کوچکتر از `in` نیست، پس کلیپ خروجی طول منفی ندارد.
- **تابع `createTrack(specs)`:** فهرستی از همان توصیفهای خام میگیرد و یک `track` برمیگرداند، یعنی شیئی به شکل `{ clips }`. هر توصیف از دل `makeClip` رد میشود و در پایان کلیپها **بر اساس زمان شروع** مرتب میشوند. یعنی ورودی میتواند به هر ترتیبی باشد و شما نباید در بقیهٔ ماژولها نگران نامرتب بودن کلیپها باشید.
- **تابع `trackDuration(track)`:** طول کل خط زمان را برمیگرداند. این عدد بزرگترین `out` میان همهٔ کلیپهاست، نه مجموع طول آنها. برای `track` خالی مقدار `0` برمیگردد.
- **توابع `clipAt(track, time)` و `clipIndexAt(track, time)`:** هر دو یک سؤال را جواب میدهند: در این لحظه کدام کلیپ پخش میشود؟ اولی خود شیء کلیپ را میدهد و دومی جایگاهش را در آرایه. تعریف «داخل کلیپ بودن» را دقیق پیاده کنید: لحظهٔ روی `in` داخل کلیپ حساب میشود ولی لحظهٔ روی `out` **نه**، چون به کلیپ بعدی تعلق دارد. اگر هیچ کلیپی آن لحظه را پوشش ندهد، `clipAt` مقدار `null` و `clipIndexAt` مقدار `-1` میدهد.
- **تابع `markers(track)`:** برای هر کلیپ یک نشانه روی زمان شروع آن میسازد. هر نشانه `id` و `time` و `label` و `kind` کلیپ را با خودش میآورد تا رابط کاربری بدون مراجعهٔ دوباره به کلیپها بتواند آن را با برچسب درست رسم کند. `chapters.js` هم ورودیاش را از همینجا میگیرد.
- **تابع `setClipWindow(track, id, inT, outT)`:** بازهٔ یک کلیپ را عوض میکند و یک `track` تازه برمیگرداند. ورودی جابهجا پذیرفته و خودش مرتب میشود و چون ممکن است کلیپ جابهجا شده باشد، کلیپهای خروجی دوباره بر اساس زمان شروع مرتب میشوند.
- **تابع `coveredLength(track)`:** مجموع زمانی را که دستکم یک کلیپ پوششش میدهد برمیگرداند و همپوشانیها را دوبار نمیشمارد؛ برای دو کلیپ `0..10` و `5..15` جواب `15` است نه `20`.
```js
const SPECS = [
{ id: "a", label: "A", kind: "start", in: 0, out: 10 },
{ id: "b", label: "B", kind: "goal", in: 10, out: 25 },
{ id: "c", label: "C", kind: "save", in: 25, out: 40 },
];
trackDuration(createTrack(SPECS)) // 40
clipAt(createTrack(SPECS), 15).id // "b"
clipIndexAt(createTrack(SPECS), 30) // 2
markers(createTrack(SPECS)).map(m => m.time) // [0, 10, 25]
```
> در نمونهٔ بالا زمان `15` داخل کلیپ `b` میافتد که بازهاش `10..25` است و زمان `30` در کلیپ سوم. مرزها همان جاییاند که اشتباه رخ میدهد: لحظهٔ `25` به کلیپ `c` تعلق دارد نه `b`، چون `out` هر کلیپ بیرون آن حساب میشود. خروجی `markers` هم زمان **شروع** هر کلیپ است، پس `[0, 10, 25]` میدهد.
</details>
<details class="orange">
<summary>**پیادهسازی فایل `playhead.js` (نشانگر پخش)**</summary>
نشانگر پخش همان خط عمودی است که روی نوار زمان جلو میرود و کل حالتش دو عدد است: زمان فعلی و طول کل. در **همهٔ این توابع** باید یک نکته رعایت شود: زمان فعلی هیچوقت از `0` کمتر و از `duration` بیشتر نمیشود. هیچکدام از این توابع هم شیء ورودی را تغییر نمیدهند و همیشه یک `playhead` تازه برمیگردانند. در قسمت زیر تعاریف و نحوه پیادهسازی هر کدام از توابع این ماژول بررسی میشوند:
|  |
| :-: |
| نشانگر پخش و نشانههای فصل روی خط زمان |
- **تابع `createPlayhead(duration)`:** نشانگر تازهای میسازد که روی زمان صفر ایستاده است، یعنی شیئی با `time` برابر `0` و `duration` برابر مقدار دادهشده. مقدار `duration` منفی پذیرفته نمیشود و به `0` تبدیل میشود.
- **تابع `seek(playhead, time)`:** نشانگر را روی یک زمان مطلق میگذارد. این همان جایی است که مهار انجام میشود: زمان بزرگتر از `duration` روی انتهای خط زمان میایستد و زمان منفی روی صفر. بقیهٔ توابع جابهجایی در نهایت از همین تابع رد میشوند.
- **تابع `step(playhead, delta)`:** نشانگر را بهاندازهٔ `delta` ثانیه جلو میبرد؛ مقدار منفی یعنی عقب رفتن. همان مهار `seek` را رعایت میکند، پس عقب رفتن بیش از حد، نشانگر را روی صفر نگه میدارد.
- **توابع `seekFraction(playhead, fraction)` و `fraction(playhead)`:** این دو تابع تبدیل زمان و موقعیت روی نوار را انجام میدهند. اولی کسری بین `0` و `1` میگیرد و نشانگر را به همان نسبت از طول کل میبرد؛ کلیک کاربر روی نوار از همین رد میشود. دومی برعکس، موقعیت فعلی را بهصورت کسری بین `0` و `1` میدهد تا عرض نوار پیشرفت از رویش ساخته شود. وقتی `duration` صفر است، `fraction` باید `0` بدهد نه `NaN`.
- **تابع `advance(playhead, dt, speed)`:** نشانگر را بهاندازهٔ `dt` ثانیه ضربدر سرعت جلو میبرد و همان مهار را رعایت میکند؛ در هر فریمِ پخش صدا زده میشود. اگر `speed` داده نشود، سرعت عادی یعنی `1` فرض میشود.
- **تابع `atEnd(playhead)`:** میگوید نشانگر به انتهای خط زمان رسیده است یا نه و مقدار بولی برمیگرداند. برای نشانگری که `duration` آن صفر است، مقدار `false` برمیگردد؛ یعنی خط زمانِ خالی «تمامشده» حساب نمیشود.
- **تابع `setDuration(playhead, duration)`:** طول کل را عوض میکند. اگر زمان فعلی بیرون بازهٔ تازه بیفتد، باید تا انتهای بازهٔ جدید عقب کشیده شود تا قانون بالا نشکند.
```js
const p = createPlayhead(40);
seek(p, 99).time // 40
seek(p, -5).time // 0
step(seek(p, 10), 3).time // 13
seekFraction(p, 0.5).time // 20
fraction(seek(p, 10)) // 0.25
advance(p, 2, 1.5).time // 3
setDuration(seek(p, 30), 20).time // 20
```
> دو خط اول مهار را نشان میدهند: `99` روی `40` میایستد و `-5` روی `0`. در مثال سوم، خروجی `seek` مستقیماً به `step` داده شده است؛ چون این توابع شیء ورودی را تغییر نمیدهند، خروجی `seek` دوباره به `step` داده شده است. اگر بهجای این، `seek` و بعد `step` را جدا روی `p` صدا بزنید، جابهجایی دوم از زمان صفر شروع میشود نه از `10`.
</details>
<details class="teal">
<summary>**پیادهسازی فایل `chapters.js` (پریدن بین لحظهها)**</summary>
این ماژول همان چیزی را میسازد که در پخشکنندههای ویدیو به آن **«فصل»** یا **«لحظه»** میگویند: نقطههایی روی خط زمان که کاربر میتواند با دو دکمهٔ بعدی و قبلی بینشان بپرد. ورودیاش فهرست نشانههایی است که `markers` در `track.js` ساخته و خروجیاش یک آرایهٔ مرتب از فصلهاست. در قسمت زیر تعاریف و نحوه پیادهسازی هر کدام از توابع این ماژول بررسی میشوند:
|  |
| :-: |
| فهرست لحظهها و فصل فعال در میان آنها |
- **تابع `createChapters(markerList)`:** نشانهها را بر اساس زمان مرتب میکند و بعد به هر فصل فیلد `index` میدهد که جایگاهش در همان آرایهٔ مرتب است. شمارهگذاری **بعد از** مرتبسازی انجام میشود، نه بر اساس ترتیب ورودی.
- **توابع `activeIndex(chapters, time)` و `activeChapter(chapters, time)`:** میگویند در این لحظه کدام فصل فعال است؛ اولی جایگاهش را میدهد و دومی خود شیء فصل را. فصل فعال آخرین فصلی است که زمان شروعش از زمان فعلی گذشته باشد. یک حالت مرزی دارند: اگر هنوز به اولین فصل نرسیده باشیم، `activeIndex` مقدار `-1` و `activeChapter` مقدار `null` میدهد.
- **تابع `nextTime(chapters, time)`:** زمان اولین فصلی را میدهد که بعد از لحظهٔ فعلی شروع میشود. اگر فصلی جلوتر نمانده باشد `null` برمیگردد و رابط کاربری با همین `null` دکمه را غیرفعال میکند.
- **تابع `prevTime(chapters, time, epsilon = 0.001)`:** دکمهٔ «قبلی» در پخشکنندههای واقعی دو رفتار متفاوت دارد و هر دو را باید پیاده کنید:
+ اگر **وسط** یک فصل باشید، دکمهٔ قبلی شما را به **ابتدای همان فصل** برمیگرداند، نه به فصل قبل. یعنی رفتارش «از اول پخش کن» است.
+ اگر **دقیقاً روی شروع** یک فصل ایستاده باشید، دکمهٔ قبلی شما را به فصل **قبلی** میبرد.
پارامتر `epsilon` تعیین میکند چقدر فاصله از شروع فصل هنوز «روی شروع» حساب شود؛ بدون این تلورانس، خطای ممیز شناور نشانگری را که عملاً روی شروع فصل است «وسط فصل» تشخیص میدهد. اگر هیچ فصلی قبلتر نمانده باشد، خروجی `null` است.
- **تابع `count(chapters)`:** تعداد فصلها را برمیگرداند.
```js
const ch = createChapters([
{ id: "a", time: 0, label: "A" },
{ id: "b", time: 10, label: "B" },
{ id: "c", time: 25, label: "C" },
]);
activeIndex(ch, 12) // 1
activeIndex(ch, 26) // 2
nextTime(ch, 12) // 25
nextTime(ch, 30) // null
prevTime(ch, 14) // 10 (restart the current chapter)
prevTime(ch, 10) // 0 (jump back one chapter)
prevTime(ch, 0) // null
```
> این سه مثال هر دو رفتار دکمهٔ «قبلی» را نشان میدهند. از زمان `14` که وسط فصل `B` است، خروجی `10` یعنی ابتدای همان فصل؛ ولی از زمان `10` که دقیقاً روی شروع `B` ایستادهایم، خروجی `0` یعنی فصل قبل. روی اولین فصل هم چیزی قبلتر نمانده و `null` برمیگردد.
</details>
<details class="purple">
<summary>**پیادهسازی فایل `trim.js` (بریدن کلیپ)**</summary>
هر کلیپ دو عدد `in` و `out` دارد که میگویند کدام بخش از آن پخش شود. کاربر میتواند این دو سر را جابهجا کند تا فقط تکهٔ موردنظرش بماند؛ به این کار **برش** میگوییم. این ماژول همین دو عدد را میسنجد و تغییر میدهد و هیچ کاری با پخش یا صفحه ندارد.
پارامتر `minLength` که در سه تابع زیر تکرار شده یک معنا دارد: **بازه هیچوقت نباید کوتاهتر از این مقدار شود.** بدون آن، کاربر میتوانست دو سر را روی هم بگذارد و کلیپی با طول صفر بسازد. در قسمت زیر تعاریف و نحوه پیادهسازی هر کدام از توابع این ماژول بررسی میشوند:
- **تابع `validateWindow(inT, outT, duration)`:** بررسی میکند بازهٔ پیشنهادی معتبر است یا نه و شیء `{ ok, reason }` برمیگرداند؛ برای بازهٔ معتبر `reason` برابر `null` است. این تابع فقط **قضاوت** میکند و چیزی را تغییر نمیدهد؛ خروجیاش برای نمایش پیام خطا به کار میآید.
- **تابع `applyTrim(clip, inT, outT, duration, minLength = 0.1)`:** برش را اعمال میکند و **یک کلیپ تازه** برمیگرداند؛ فیلدهایی مثل `id` و `label` سر جایشان میمانند و فقط `in` و `out` عوض میشوند. برخلاف `validateWindow` ورودی نامرتب را رد نمیکند و خروجیاش همیشه سه شرط را برآورده میکند: `in` از `out` کوچکتر است، هر دو داخل `0` تا `duration` میمانند و فاصلهشان دستکم `minLength` است.
- **توابع `setIn(clip, time, minLength = 0.1)` و `setOut(clip, time, minLength = 0.1)`:** هرکدام فقط یک سر بازه را جابهجا میکنند و کلیپ تازه برمیگردانند. اگر سر تازه به سر دیگر نزدیکتر از `minLength` شود، سر دیگر هم به همان اندازه هل داده میشود تا حداقل طول حفظ بماند. هیچکدام از دو سر زیر صفر نمیروند.
- **تابع `windowLength(clip)`:** طول بازه یعنی فاصلهٔ `in` تا `out` را برمیگرداند و هیچوقت عدد منفی نمیدهد.
بازه در چهار حالت نامعتبر است و برای هرکدام `reason` مقدار مشخصی دارد:
| **حالت** | **مقدار** `reason` |
| --: | --: |
| یکی از دو مقدار عدد نیست | `"non-numeric"` |
| یکی از دو مقدار منفی است | `"negative"` |
| مقدار `out` از طول کل بیشتر است | `"out-of-range"` |
| مقدار `in` از `out` کوچکتر نیست | `"inverted"` |
ترتیب این بررسیها اهمیت دارد و باید از بالا به پایین همین جدول باشد، چون یک ورودی میتواند همزمان چند شرط را بشکند و تستها انتظار دارند اولین دلیل گزارش شود.
```js modules/trim.js js
validateWindow(2, 8, 40) // { ok: true, reason: null }
validateWindow(8, 2, 40).reason // "inverted"
validateWindow(2, 50, 40).reason // "out-of-range"
validateWindow(-1, 8, 40).reason // "negative"
validateWindow(NaN, 8, 40).reason // "non-numeric"
applyTrim({ id: "a", in: 0, out: 10 }, 6, 2, 40)
// { id: "a", in: 2, out: 6 } <- swapped inputs get sorted, id is kept
setIn({ id: "a", in: 0, out: 5 }, 4.95)
// { id: "a", in: 4.95, out: 5.05 } <- out was pushed to keep minLength
windowLength({ in: 2, out: 7 }) // 5
```
> دو تابع اصلی این ماژول را با یک ورودی مقایسه کنید: بازهٔ جابهجای `(8, 2)` از دید `validateWindow` نامعتبر است و `"inverted"` میگیرد، ولی `applyTrim` با همان ورودی کار میکند و خودش مرتبش میکند. مثال `setIn` هم نقش `minLength` را نشان میدهد: سر ابتدا روی `4.95` فقط `0.05` با `out` فاصله دارد، پس `out` تا `5.05` هل داده میشود. به فیلد `id` در خروجیها دقت کنید؛ کلیپ کپی میشود و بقیهٔ فیلدها سر جایشان میمانند.
|  |
| :-: |
| بازهٔ برش مشخصشده روی یک کلیپ |
</details>
<details class="yellow">
<summary>**پیادهسازی فایل `speed.js` (سرعت پخش)**</summary>
این ماژول دو کار جدا انجام میدهد که بهتر است از اول از هم تفکیکشان کنید. **مهار** یعنی هیچ عددی نتواند سرعت را از دو سر بازه بیرون ببرد و **پلهای کردن** یعنی هر مقدار دلخواه به یکی از پنج پلهٔ مجاز بچسبد، چون نوار سرعت پیوسته نیست. این توابع حالت برنامه را نگه نمیدارند و فقط روی سرعت فعلی حساب میکنند.
|  |
| :-: |
| انتخاب سرعت پخش از میان مقادیر مجاز |
```js
export const SPEEDS = [0.25, 0.5, 1, 1.5, 2];
```
> این آرایه پلههای مجاز سرعت پخش است و ترتیبش از کند به تند اهمیت دارد، چون دکمههای سرعت بر اساس همین ترتیب بین پلهها جابهجا میشوند. مقدار `1` سرعت عادی است و باید حتماً در فهرست باشد.
- **تابع `clampSpeed(value)`:** عدد ورودی را همانطور برمیگرداند، مگر اینکه از کف یا سقف `SPEEDS` بیرون زده باشد که در آن صورت به نزدیکترین سر بازه میچسبد. این تابع **پلهای نمیکند**؛ مقدار `0.8` که داخل بازه است دستنخورده برمیگردد. یک حالت مرزی هم دارد: اگر ورودی عدد نباشد، بهجای خطا یا `NaN` باید سرعت عادی یعنی `1` برگردد تا یک مقدار خراب از رابط کاربری پخش را قفل نکند.
- **تابع `snap(value)`:** نزدیکترین پلهٔ `SPEEDS` را برمیگرداند، پس خروجیاش **همیشه** یکی از همان پنج مقدار است. ورودی بیرون بازه هم پذیرفته میشود و به نزدیکترین سر میرسد. تفاوتش با `clampSpeed` همین است: آن یکی فقط جلوی بیرونزدن را میگیرد، این یکی مقدار را روی شبکهٔ پلهها مینشاند.
- **تابع `indexOf(value)`:** جایگاه یک سرعت را در `SPEEDS` میدهد؛ پلهٔ اول جایگاه `0` دارد. ورودی لازم نیست دقیقاً یکی از پلهها باشد و برای مقدار میانی هم جایگاه نزدیکترین پله برمیگردد، پس این تابع هیچوقت `-1` نمیدهد.
- **تابع `stepSpeed(value, direction)`:** یک پله بالا یا پایین میرود و پلهٔ تازه را برمیگرداند؛ `direction` مثبت یعنی تندتر و منفی یعنی کندتر. روی بالاترین پله، زدن دکمهٔ تندتر باید همان بالاترین پله را بدهد و برای پایینترین پله هم به همین شکل؛ یعنی از دو سر فهرست بیرون نمیزند و خطا هم نمیدهد.
- **تابع `scaledDelta(dt, speed)`:** فاصلهٔ زمانی یک فریم را در سرعت پخش ضرب میکند تا معلوم شود نشانگر چقدر جلو برود. سرعت را **پیش از ضرب** مهار میکند، پس عدد بیمعنایی مثل `9` هم نتیجه را از سقف مجاز فراتر نمیبرد.
```js
clampSpeed(0.1) // 0.25
clampSpeed(5) // 2
clampSpeed("x") // 1 (a non-numeric input falls back to normal speed)
snap(0.6) // 0.5
snap(1.7) // 1.5
indexOf(1) // 2
stepSpeed(1, 1) // 1.5
stepSpeed(2, 1) // 2 (already at the top)
stepSpeed(0.25, -1) // 0.25 (already at the bottom)
scaledDelta(2, 1.5) // 3
scaledDelta(2, 9) // 4 (speed clamped to 2 first)
```
> تفاوت مهار و پلهای کردن را در خطوط اول ببینید: `0.1` و `5` بیرون بازهاند و به دو سر میچسبند، ولی `0.6` که داخل بازه است تنها با `snap` به `0.5` میرسد. دو خط `stepSpeed` رفتار اشباع را نشان میدهند؛ فهرست دور نمیزند. در مثال آخر، سرعت ابتدا تا سقف مجاز محدود میشود: برای سرعت `9` نتیجه `18` نیست، چون سرعت اول تا سقف `2` مهار میشود و بعد ضرب انجام میگیرد.
</details>
<details class="olive">
<summary>**پیادهسازی فایل `clock.js` (ساعت پخش)**</summary>
کار ساعت فقط یک چیز است: اندازهگیری فاصلهٔ زمانی از تیک قبلی! برای اینکه تستها قطعی بمانند، منبع زمان از بیرون تزریق میشود:
```js
export function createClock(onTick, now = () => performance.now()) { /* ... */ }
```
> پارامتر `onTick` تابعی است که در هر تیک صدا زده میشود و پارامتر دوم منبع زمان است. تزریق منبع زمان عمدی است: در تست میشود یک تابع ساختگی داد و زمان را دستی جلو برد، بدون اینکه واقعاً منتظر بمانیم. نکتهٔ اصلی ماژول هم همینجاست؛ هر تیک فاصله از **تیک قبلی** را میدهد نه از لحظهٔ شروع، پس با تیکهایی در میلیثانیهٔ `500` و `800`، خروجیها بهترتیب `0.5` و `0.3` ثانیهاند.
تابع `createClock(onTick, now)` یک **شیء ساعت** برمیگرداند، نه عدد و نه رشته. پارامتر دومش اختیاری است و اگر ندهید، از زمان واقعی مرورگر استفاده میشود. شیء برگشتی این متدها را دارد:
+ `start()` و `stop()` ساعت را روشن و خاموش میکنند و `toggle()` وضعیت را برعکس میکند.
+ `isPlaying()` وضعیت فعلی ساعت را بهصورت بولی برمیگرداند.
+ `tick()` که فاصلهٔ زمانی از تیک قبلی را **بر حسب ثانیه** حساب میکند، به `onTick` میدهد و همان را هم برمیگرداند.
+ **نکته:** وقتی ساعت متوقف است، `tick()` نباید `onTick` را صدا بزند.
</details>
<details class="pink">
<summary>**پیادهسازی فایل `loop.js` (تکرار یک بازه)**</summary>
لوپ همان قابلیتی است که در نرمافزارهای تدوین به آن `A/B loop` میگویند: کاربر دو نقطه روی خط زمان میگذارد و پخشکننده همان بازه را بیوقفه تکرار میکند. لوپ دو حالت دارد و همهٔ توابع باید هر دو را درست مدیریت کنند: **نیمهکاره** یعنی فقط یکی از دو نقطه گذاشته شده و لوپ هیچ اثری روی پخش ندارد و **فعال** یعنی هر دو نقطه هست و بازهٔ واقعی میسازند.
|  |
| :-: |
| بازهٔ تکرار میان دو نقطهٔ `A` و `B` |
- **تابع `createLoop()`:** حلقهٔ خالی میسازد، یعنی شیئی که هر دو نقطهاش `null` است. مقدار `null` اینجا معنادار است و با `0` فرق دارد: `null` یعنی «کاربر هنوز این نقطه را نگذاشته»، ولی `0` یعنی «نقطه روی ثانیهٔ صفر گذاشته شده».
- **توابع `setA(loop, time)` و `setB(loop, time)`:** یکی از دو نقطه را روی زمان دادهشده میگذارند و حلقهٔ تازه برمیگردانند. نکتهاش این است که کاربر ممکن است نقطهٔ پایان را جلوتر از نقطهٔ شروع بگذارد؛ در آن حالت این توابع خودشان دو نقطه را مرتب میکنند تا شرط «`a` همیشه از `b` کوچکتر است» نشکند.
- **تابع `normalize(loop)`:** همان مرتبسازی را روی یک حلقهٔ آماده انجام میدهد: اگر دو نقطه جابهجا باشند جایشان را عوض میکند، وگرنه حلقه را دستنخورده برمیگرداند. حلقهای که یکی از نقطههایش `null` است چیزی برای مرتب کردن ندارد.
- **تابع `isActive(loop)`:** میگوید حلقه واقعاً فعال است یا نه و مقدار بولی برمیگرداند. فعال بودن دو شرط دارد: هر دو نقطه گذاشته شده باشند و بازه طول واقعی داشته باشد یعنی `b` اکیداً از `a` بزرگتر باشد. حلقهای که هر دو نقطهاش روی یک زمان است فعال حساب نمیشود.
- **تابع `loopLength(loop)`:** طول بازهٔ تکرار را برمیگرداند. برای حلقهای که فعال نیست، مقدار `0` است؛ یعنی این تابع هیچوقت `NaN` یا عدد منفی نمیدهد.
- **تابع `contains(loop, time)`:** میگوید این لحظه داخل بازهٔ تکرار میافتد یا نه. برخلاف `clipAt` در `track.js`، اینجا **هر دو** مرز داخل بازه حساب میشوند. برای حلقهٔ غیرفعال همیشه `false` برمیگردد.
- **تابع `wrap(loop, time)`:** این تابع منطق اصلی تکرار بازه را پیاده میکند. اگر زمان دادهشده از انتهای بازه گذشته باشد، مقدار متناظرش را از ابتدای بازه برمیگرداند؛ یعنی همانقدر که از `b` جلو زده، از `a` جلو میرود. اگر لوپ فعال نباشد یا زمان هنوز به انتهای بازه نرسیده باشد، همان زمان بدون تغییر برمیگردد. برای پرشهای خیلی بزرگ هم باید کار کند و نتیجه همیشه داخل بازه بماند.
- **تابع `clearLoop()`:** حلقه را پاک میکند و همان حلقهٔ خالی اولیه را برمیگرداند.
```js
setB(setA(createLoop(), 10), 4) // { a: 4, b: 10 } (auto-ordered)
loopLength({ a: 5, b: 12 }) // 7
contains({ a: 5, b: 10 }, 7) // true
wrap({ a: 5, b: 10 }, 11) // 6
wrap({ a: 5, b: 10 }, 7) // 7
wrap(createLoop(), 42) // 42 (no loop, no change)
```
> خط اول نشان میدهد نقطههای جابهجا خودشان مرتب میشوند: `A` روی `10` و `B` روی `4` گذاشته شده، ولی خروجی `a` برابر `4` و `b` برابر `10` است. سه خط آخر سه حالت `wrap` را روشن میکنند: زمان `11` یک ثانیه از انتهای بازهٔ `5..10` گذشته پس به `6` برمیگردد، زمان `7` داخل بازه است و دستنخورده میماند و در خط آخر لوپ فعال نیست پس زمان `42` بدون تغییر عبور میکند.
</details>
<details class="brown">
<summary>**پیادهسازی فایل `filmstrip.js` (نوار `filmstrip`)**</summary>
نوار `filmstrip` همان نوار باریکی است که زیر خط زمان کل فیلم را در چند قاب کوچک خلاصه میکند. اینجا تصویری در کار نیست؛ خط زمان را به چند بخش مساوی تقسیم میکنید و برای هر بخش میگویید در آن لحظه کدام کلیپ پخش میشود، تا رابط کاربری بتواند سلولها را رنگ کند و برچسب بزند.
|  |
| :-: |
| سلولهای پیشنمایش که کل فیلم را خلاصه میکنند |
قاعدهٔ نمونهبرداری این است که از **وسط** هر بخش نمونه بردارید، نه از ابتدای آن؛ برای فیلم ۴۰ ثانیهای و هشت سلول، نمونهها روی ۲.۵ و ۷.۵ و ۱۲.۵ و... میافتند. نمونهٔ ابتدای بخش دقیقاً روی مرز کلیپها میافتد و سلول را نمایندهٔ کلیپ اشتباهی میکند.
- **تابع `buildCells(track, count)`:** به تعداد `count` سلول میسازد. هر سلول شش فیلد دارد: `index` جایگاهش در نوار، `time` زمان نمونهبرداری، `fraction` همان زمان بهصورت نسبتی بین `0` و `1` و `clipId` و `kind` و `label` که از کلیپ فعال در آن لحظه برداشته میشوند. اگر آن لحظه هیچ کلیپی فعال نباشد، نزدیکترین کلیپِ شروعشده جایش را میگیرد؛ و اگر `track` اصلاً کلیپی نداشته باشد، `kind` برابر `"empty"` و `label` رشتهٔ خالی میشود. تعداد کمتر از یک هم پذیرفته نمیشود و نوار همیشه دستکم یک سلول دارد.
- **تابع `cellAtFraction(count, fraction)`:** نسبتی بین `0` و `1` میگیرد و میگوید نشانگر ماوس روی کدام سلول نوار ایستاده است. خروجی همیشه یک اندیس معتبر بین `0` و `count - 1` است، پس نسبتهای بیرون بازه به نزدیکترین سلول دو سر نوار میرسند و نسبت `1` هم به آخرین سلول میرسد، نه به سلولی که وجود ندارد.
- **تابع `previewAt(track, fraction)`:** اطلاعاتی را برمیگرداند که هنگام قرار گرفتن ماوس روی نوار نمایش داده میشود. نسبتی بین `0` و `1` میگیرد و شیئی با چهار فیلد `time` و `fraction` و `label` و `kind` برمیگرداند: زمان متناظر آن نقطه و مشخصات کلیپی که آن لحظه پخش میشود. تفاوتش با `buildCells` این است که آنجا نمونهها روی وسط بخشهای ثابت میافتند، ولی اینجا زمان دقیقاً از روی نسبت دادهشده حساب میشود.
```js
const cells = buildCells(createTrack(SPECS), 8);
cells[0].time // 2.5
cells[7].time // 37.5
cells[0].kind // "start"
cells[6].label // "C"
cellAtFraction(8, 0.5) // 4
cellAtFraction(8, 0.99) // 7
cellAtFraction(8, -0.4) // 0 (clamped)
cellAtFraction(8, 1.5) // 7 (clamped)
```
> نمونهبرداری از وسط بخشها را در دو خط اول ببینید: با طول `40` و هشت سلول، اولین سلول روی `2.5` میافتد و آخرین روی `37.5`، نه روی `0` و `35`. چهار خط آخر هم مهار `cellAtFraction` را نشان میدهند؛ نسبت `0.99` به آخرین سلول میرسد و نسبتهای بیرون بازه به دو سر نوار.
</details>
<details class="grey">
<summary>**پیادهسازی `app.js` (وصل کردن ماژولها به صفحه)**</summary>
**تمام عنصرهای زیر از قبل در `index.html` هستند و همگی `data-testid` خودشان را دارند.** در `app.js` هم شیء `el` از قبل به هر کدام اشاره میکند. کار شما ساختن هیچ عنصری نیست؛ کار شما این است که **محتوا و وضعیتشان را از روی مدل پخشکننده بهروز کنید.**
فایل `app.js` باید شیء `window.player` را بسازد. سیستم داوری از طریق همین شیء با پخشکننده کار میکند، پس این متدها باید دقیقاً با همین نامها وجود داشته باشند:
+ `seek(time)` و `seekFraction(f)` نشانگر پخش را جابهجا میکنند؛ `getTime()` زمان فعلی و `getDuration()` طول کل را برمیگردانند.
+ `play()` و `pause()` پخش را روشن و خاموش میکنند و `isPlaying()` وضعیت فعلی را بهصورت بولی میدهد.
+ `advanceBy(dt)` پخش را بهاندازهٔ `dt` ثانیه جلو میبرد.
+ `setSpeed(value)` سرعت را میگذارد و `getSpeed()` سرعت فعلی را برمیگرداند.
+ `nextChapter()` و `prevChapter()` نشانگر را به فصل بعدی یا قبلی میبرند.
+ `trimSetIn()` و `trimSetOut()` سرِ ابتدا یا انتهای بازهٔ برش را روی **زمان فعلی** میگذارند.
+ `loopSetA()` و `loopSetB()` نقطهٔ ابتدا یا انتهای حلقه را روی زمان فعلی میگذارند و `getLoop()` حلقهٔ فعلی را برمیگرداند.
+ `activeClip()` که کلیپ فعال فعلی را میدهد.
+ `state` که باید `state.chapters` و `state.filmstrip` را داشته باشد.
|  |
| :-: |
| چیدمان کامل پخشکننده با همهٔ کنترلها |
**آنچه باید در هر بار رندر بهروز شود:**
| **عنصر** | **کاری که `app.js` میکند** |
| :-: | :-: |
| `stage-clip` و `stage-kind` | نام و نوع کلیپ فعال را مینویسد |
| `current-time` و `total-time` | زمان فعلی و طول کل را با `formatTime` مینویسد |
| `playhead` | مقدار `style.left` را از نسبت زمان میگذارد |
| `progress` | مقدار `style.width` را از همان نسبت میگذارد؛ در زمان صفر باید صفر و در انتها بیش از ۹۰ درصد باشد |
| `timeline` | مقدار `aria-valuenow` را با **دو رقم اعشار** مینویسد، مثل `12.00` |
| `minimap-window` | مقدار `style.left` را از نسبت زمان میگذارد |
| `play-pause` | مقدار `aria-pressed` را `"true"`/`"false"` میکند و هنگام پخش متنش شامل `Pause` میشود |
| `speed-select` | مقدار `value` را با سرعت فعلی مدل همگام نگه میدارد |
| `loop-region` | مقدار `style.width` و `data-active` را میگذارد؛ `data-active` در شروع `"false"` است و بعد از تعیین هر دو نقطه `"true"` |
| `trim-region` و `trim-in-value` و `trim-out-value` | بازهٔ کلیپ فعال را نشان میدهند؛ عرض `trim-region` از همان بارگذاری اول باید غیرصفر باشد |
**آنچه باید پیادهسازی شود:** چهار ظرف `ticks` و `markers` و `chapter-list` و `filmstrip` در `index.html` **خالی** هستند و `app.js` باید پرشان کند:
+ `ticks` بیش از یک خطکش میگیرد؛ کاملاً تزئینی است.
+ `markers` برای هر فصل یک `marker-<id>` میگیرد؛ هرکدام `aria-label` دارند، با `style.left` جایگذاری میشوند و با کلیک، پخش به آن لحظه میپرد.
+ `chapter-list` برای هر فصل یک `chapter-item-<id>` میگیرد؛ کلیک روی هر مورد نشانگر را به زمان همان فصل میبرد و فصل فعال `aria-current="true"` میگیرد.
+ `filmstrip` برای هر سلول یک `film-cell-<index>` میگیرد و کلیک روی هر سلول نشانگر را به زمان همان سلول میبرد.
**پیشنمایش:** با حرکت موس روی خط زمان، `preview-flag` باید `data-show="true"` بگیرد و `preview-time` زمان آن نقطه را نشان بدهد. با خارج شدن موس دوباره `"false"` میشود.
**کیبورد:**
+ روی خط زمان: `ArrowRight` و `ArrowLeft` جلو و عقب، `Home` به ابتدا، `End` به انتها.
+ در سطح `document`: کلید `Space` پخش و توقف را انجام میدهد، `.` و `,` گام کوچک جلو و عقب میروند و `[` و `]` نقطهٔ شروع و پایان برش را تعیین میکنند. این کلیدها فقط وقتی کار میکنند که تمرکز روی یک فیلد متنی یا عنصر قابل ویرایش نباشد.
+ **نکته:** کلید `Space` را با `event.code === "Space"` تشخیص بدهید، نه با `event.key`. سیستم داوری این رویداد را فقط با `code` میفرستد و `event.key` در `handler` شما `undefined` خواهد بود.
**نکته:** زمان فعلی، سرعت پخش و بازهٔ برش هر کلیپ باید در `localStorage` زیر کلید `road-highlight-replay` ذخیره شوند و بعد از رفرش برگردند. بازهٔ تکرار (`loop`) ذخیره نمیشود و با هر بار باز شدن صفحه خالی است.
+ **نکته:** اگر مقدار ذخیرهشده خراب باشد، صفحه باید سالم بالا بیاید و از صفر شروع کند. توابع `loadState` و `saveState` از قبل در `app.js` نوشته شدهاند و همین حالت را مدیریت میکنند.
</details>
# **آنچه باید آپلود کنید**
- **توجه:** فایلی که آپلود میکنید باید فرمت زیپ داشته باشد و ساختارش اینطور باشد:
```plaintext
├── modules/
│ ├── app.js
│ ├── chapters.js
│ ├── clock.js
│ ├── data.js
│ ├── filmstrip.js
│ ├── format.js
│ ├── loop.js
│ ├── playhead.js
│ ├── speed.js
│ ├── track.js
│ └── trim.js
├── index.html
└── styles.css
```
+ **توجه:** درخت بالا دقیقاً همان چیزی است که باید در فایل زیپ آپلود کنید. پوشهٔ `modules/` و دو فایل `index.html` و `styles.css` باید در ریشهٔ فایل زیپ باشند، نه داخل یک پوشهٔ اضافه. اگر همهچیز را داخل پوشهای مثل `answer/` بگذارید، مسیرهای موردانتظار پیدا نمیشوند و داوری اجرا نمیشود.
- **توجه: فایل جدیدی نسازید!** سیستم داوری کوئرا فقط فایلهای بالا را برمیدارد، پس اگر ماژول جدیدی بسازید و از جایی `import`ش کنید، آن ماژول در داوری وجود نخواهد داشت و بارگذاری کل صفحه شکست میخورد. کد کمکی را داخل همان فایلهای موجود بنویسید.
- **توجه:** تمام متنهای داخل صفحه **انگلیسی** هستند.
- **توجه:** پخش نباید از انتهای خط زمان جلوتر برود و وقتی به انتها رسید، باید خودش متوقف شود. اگر کاربر دوباره از روی انتها `play` بزند، پخش از صفر شروع میشود.
- **توجه:** داوری به رنگ و فونت و پیکسل کاری ندارد. مقدار بازگشتی توابع، رفتار صفحه و `data-testid`ها بررسی میشوند.
بازپخش لحظهها
**«تیفو»** همان طرح بزرگی است که تماشاگران با بالا بردن همزمان کارتهای رنگی روی جایگاه میسازند و **هر صندلی یک پیکسل از تصویر است!** این طرح از قبل روی یک شبکه کشیده میشود: هر سلول یک رنگ میگیرد و در روز بازی همان رنگ به دست تماشاگر آن صندلی میرسد. در این سوال باید همین ابزار طراحی را پیادهسازی کنید؛ ویرایشگری که با آن میشود روی صندلیها نقاشی کرد، لایهٔ متن و شکل اضافه کرد و دید **طرح** موقع اجرا چطور خانهبهخانه ظاهر میشود. منطق برنامه باید در ماژولهای جدا از هم نوشته شود و این ماژولها نباید به `DOM` دسترسی داشته باشند و رسم روی `canvas` انجام میگیرد. پیادهسازی کل این چالش با `JavaScript` است.

**هدف این سوال پیادهسازی مدلسازی داده و الگوریتمهای کار روی شبکه است**: ابزارهای نقاشی، لایهها، ناحیهٔ انتخاب و ترتیبهای نمایش تدریجی. ظاهر برنامه در ارزیابی نقشی **ندارد** و سیستم داوری کوئرا فقط خروجی ماژولها و رفتار برنامه را میسنجد.
# **پروژهٔ اولیه**
پروژهٔ اولیه را از [این لینک](/contest/assignments/103143/download_problem_initial_project/356840/) دانلود کنید.
<details class="green">
<summary>**ساختار فایلها و پروژه اولیه**</summary>
```plaintext
initial_project/
├─ <mark class="green" title="آماده است؛ تغییرش ندهید">index.html</mark>
├─ <mark class="green" title="آماده است؛ تغییرش ندهید">styles.css</mark>
└─ scripts/
├─ <mark class="orange" title="این فایل را تکمیل کنید">grid.js</mark>
├─ <mark class="orange" title="این فایل را تکمیل کنید">paint.js</mark>
├─ <mark class="orange" title="این فایل را تکمیل کنید">palette.js</mark>
├─ <mark class="orange" title="این فایل را تکمیل کنید">layers.js</mark>
├─ <mark class="orange" title="این فایل را تکمیل کنید">font.js</mark>
├─ <mark class="orange" title="این فایل را تکمیل کنید">select.js</mark>
├─ <mark class="orange" title="این فایل را تکمیل کنید">reveal.js</mark>
├─ <mark class="orange" title="این فایل را تکمیل کنید">playback.js</mark>
├─ <mark class="orange" title="این فایل را تکمیل کنید">templates.js</mark>
├─ <mark class="orange" title="این فایل را تکمیل کنید">history.js</mark>
├─ <mark class="orange" title="این فایل را تکمیل کنید">io.js</mark>
└─ <mark class="orange" title="این فایل را تکمیل کنید">main.js</mark>
```
+ **نکته:** داخل فایلهای پروژهٔ اولیه، در هر بخش کامنتهایی جهت انجام راهنمایی برای پیادهسازی قرار گرفتهاند: قرارداد هر تابع، حالتهای مرزی و نکتههای پیادهسازی. پیش از شروع هر فایل، کامنتهای بالای آن را بخوانید.
+ **نکته:** درخت بالا ساختار پروژه است. **دو فایل `index.html` و `styles.css` کامل و آماده در پروژهٔ اولیه هستند و نباید تغییرشان بدهید**؛ اگر همین حالا صفحه را باز کنید، کل استودیو با نوار ابزار و `Canvas` و پالت و پنل لایهها و کنترلهای نمایش تدریجی سر جایشاناند. منطق در یازده ماژول مستقل داخل `scripts/` نوشته میشود و هیچکدام نباید به `DOM` دسترسی داشته باشند؛ `main.js` تنها جایی است که به صفحه و `Canvas` وصل میشود.
+ **نکته:** نام فایلها و نام `export`ها را عوض نکنید. سیستم داوری ماژولها را از `window.studioApi` میگیرد و اگر ماژولی جا بیفتد، آن شیء ناقص میماند.
+ **نکته:** در هر فایل، اسکلت همهٔ `export`های لازم با بدنهٔ `TODO` آمده است. نام فایلها و نام `export`ها را تغییر ندهید.
+ **نکته:** برخلاف بقیهٔ سوالها، سیستم داوری اینجا ماژولها را مستقیم `import` نمیکند. صفحه را در مرورگر باز میکند و از طریق دو شیء `window.studioApi` و `window.studio` با کد شما کار میکند. پس اگر `main.js` این دو شیء را نسازد، **هیچ تستی پاس نمیشود.**
در `main.js` هم شیء `el` از قبل به تکتک عنصرهای صفحه اشاره میکند، دو مرحلهٔ رندر و رفتار نوار ابزار بهعنوان الگو نوشته شدهاند و جای بقیه با کامنت `TODO` و راهنما مشخص است. پس هیچوقت لازم نیست دنبال سلکتور بگردید یا نشانهگذاری بسازید؛ فقط منطق و رفتار را مینویسید.
</details>
# **جزئیات پیادهسازی**
منطق برنامه در یازده ماژول مستقل نوشته میشود که به `DOM` دسترسی ندارند. فایل `main.js` این ماژولها را به رابط کاربری و `Canvas` وصل میکند.
+ **نکته:** برخلاف بیشتر سوالها، توابع این سوال دادهٔ ورودی را مستقیم تغییر میدهند. توابع `grid.js` و `paint.js` و `select.js` و همچنین `moveLayer` و `setVisible` در `layers.js` روی همان ساختار ورودی کار میکنند و کپی تازه نمیسازند؛ مقدار بازگشتیشان فهرست سلولهای تغییرکرده است، نه شبکهٔ جدید. تنها استثنا `composite` است که شبکهٔ پایه را دستنخورده نگه میدارد و شبکهٔ تازهای برمیگرداند.
|  |
| :-: |
| نمایان شدن خانهبهخانهٔ طرح روی شبکه؛ همین جریان را در اجرای واقعی برنامه نشان میدهد |
<details class="green">
<summary>**ترتیب پیشنهادی برای پیادهسازی سوال**</summary>
1. `grid.js` مدل پایه است و بقیه ماژولها به آن نیاز دارند.
2. `paint.js` و `palette.js` مستقیم روی مدل کار میکنند.
3. `font.js` و `layers.js` میتوانند با هم پیش بروند.
4. `select.js` و `reveal.js` و `playback.js` هرکدام مستقلاند.
5. `templates.js` و `history.js` و `io.js` کوچکاند.
6. و آخر از همه به سراغ پیادهسازی `main.js` بروید.
</details>
<details class="blue">
<summary>**ساختار داده**</summary>
**شبکه** مستطیلی از سلولهاست. هر سلول یک شیء `{ color, glyph }` دارد و سلولِ نقاشینشده مقدار `EMPTY` میگیرد:
```js
export const EMPTY = null;
// cells holds rows x cols entries in row-major order
const grid = { rows: 24, cols: 40, cells: [] };
```
> شبکه مستطیلی از سلولهاست و هر سلول یک شیء با رنگ و نویسه است. مقدار `EMPTY` نشان میدهد سلول هنوز رنگ نشده و همهجا بهجای رنگ خالی از همین ثابت استفاده میشود، تا مقایسهها یکدست بماند.
>
> آرایهٔ `cells` دقیقاً `rows × cols` عضو دارد و بهصورت سطری پر میشود، یعنی اندیس هر سلول از شمارهٔ سطرش ضربدر تعداد ستونها بهعلاوهٔ شمارهٔ ستونش بهدست میآید. همین رابطه پایهٔ همهٔ توابع `grid.js` است.
**لایه** محتوایی است که روی شبکه قرار میگیرد:
```js
{ id: 1, row: 0, col: 0, width: 7, height: 5, visible: true,
cells: [ { row: 0, col: 1, color: "#fff" } ] }
```
> این مثال ساختار یک **لایه** را نمایش میدهد. هر لایه یک مستطیل روی شبکه اشغال میکند که با `row` و `col` و ابعادش مشخص میشود و `visible` میگوید در ترکیب نهایی دیده شود یا نه. لایهها روی شبکهٔ پایه سوار میشوند و ترتیبشان در پشته تعیین میکند کدام روی کدام بیفتد.
>
> مختصات داخل `cells` هر لایه نسبت به خودِ لایه است، نه نسبت به شبکه. برای گرفتن مختصات مطلق باید `row` و `col` لایه به آنها اضافه شود.
مختصات داخل `cells` **نسبت به خود لایه** است. `absoluteCells(layer)` آنها را با `row` و `col` لایه جمع میکند تا مختصات نهایی روی شبکه به دست بیاید. **انتخاب** یک مستطیل است:
```js
{ top: 1, left: 2, bottom: 3, right: 4 }
```
> ناحیهٔ انتخاب یک مستطیل روی شبکه است که با چهار مختصات مشخص میشود. هر چهار مرز شامل خودشاناند، یعنی سطر `top` و سطر `bottom` هر دو داخل انتخاب حساب میشوند.
>
> توابع `select.js` همیشه انتخاب را به همین شکل میگیرند و برمیگردانند، حتی وقتی کاربر از پایین به بالا انتخاب کرده باشد.
**سند** چیزی است که ذخیره و بازیابی میشود:
```js
{ grid, layers, palette, revealMode }
```
> این شکل **سند** است؛ همان چیزی که ذخیره و بازیابی میشود. شبکهٔ پایه، پشتهٔ لایهها، پالت رنگ و حالت نمایش تدریجی، چهار چیزیاند که با هم یک طرح کامل را میسازند.
>
> هنگام ذخیره همین شیء به `JSON` تبدیل میشود، پس نباید هیچ مقدار غیرقابلسریالسازی مثل تابع یا `undefined` داخلش بگذارید.
</details>
<details class="green">
<summary>**پیادهسازی فایل `grid.js`**</summary>
**شبکه** پایهایترین ساختار این سوال است و هر ماژول دیگری در نهایت روی همین کار میکند. سلولها در یک آرایهٔ **تخت** نگه داشته میشوند، نه آرایهٔ تودرتو؛ یعنی بهجای `cells[row][col]` یک آرایهٔ یکبعدی دارید که سطربهسطر پر شده است. برای همین اولین چیزی که باید بسازید تبدیل مختصات دوبعدی به اندیس تخت است و بقیهٔ توابع روی آن سوار میشوند.
**نکتهٔ دوم** که در کل این سوال با شما میماند: توابعی که شبکه را تغییر میدهند، آن را **در جای خود** عوض میکنند و کپی تازه نمیسازند. تنها استثناها `cloneGrid` و `resizeGrid` و `toJSON` هستند که صریحاً ساختار تازه میسازند. در قسمت زیر تعاریف و نحوه پیادهسازی هر کدام از توابع این ماژول بررسی میشوند:
- **تابع `createGrid(rows, cols)`:** شبکهٔ تازهای میسازد و شیئی با سه فیلد `rows` و `cols` و `cells` برمیگرداند. هر سلول شیئی به شکل `{ color, glyph }` است که در ابتدا رنگش `EMPTY` و نویسهاش رشتهٔ خالی است. ابعاد کمتر از یک پذیرفته نمیشوند و به `1` گرد میشوند، پس شبکه هیچوقت خالی از سلول نیست.
- **توابع `indexOf(grid, row, col)` و `inBounds(grid, row, col)`:** این دو پایهٔ همهچیزند. اولی مختصات دوبعدی را به اندیس داخل آرایهٔ `cells` تبدیل میکند و برای مختصات بیرون شبکه `-1` میدهد. دومی همان بررسی را بهصورت بولی جواب میدهد. با پیادهسازی درست این دو تابع، بیشتر توابع این ماژول ساده خواهند بود.
- **توابع `getCell(grid, row, col)` و `getColor(grid, row, col)`:** اولی خودِ شیء سلول را میدهد و برای مختصات بیرون شبکه `null`. دومی فقط رنگ همان سلول را میدهد؛ رنگ سلولِ نقاشینشده `EMPTY` است و برای مختصات بیرون شبکه هم همان `EMPTY` برمیگردد، نه خطا. این رفتار عمدی است: ابزارهای رسم مدام رنگ همسایهها را میپرسند و نباید مجبور باشند هر بار مرز شبکه را جدا بررسی کنند.
- **توابع `setColor(grid, row, col, color)` و `setGlyph(grid, row, col, glyph)`:** رنگ یا نویسهٔ یک سلول را **در همان شبکه** عوض میکنند و مقدار بولی برمیگردانند که میگوید تغییر انجام شد یا نه. برای مختصات بیرون شبکه هیچ کاری نمیکنند و `false` میدهند. رابط کاربری از همین مقدار بولی استفاده میکند تا بفهمد لازم است دوباره رسم کند یا نه.
- **تابع `clearGrid(grid)`:** همهٔ سلولهای همان شبکه را به حالت خالی برمیگرداند. ابعاد شبکه دستنخورده میماند و فقط محتوای سلولها پاک میشود.
- **توابع `paintedCount(grid)` و `cellCount(grid)`:** اولی تعداد سلولهای **رنگشده** و دومی تعداد **کل** سلولهای شبکه را برمیگرداند. نوار وضعیت برنامه از نسبت این دو استفاده میکند تا درصد پوشش طرح را نشان بدهد.
- **تابع `resizeGrid(grid, rows, cols)`:** شبکهای با ابعاد تازه میسازد و **شبکهٔ جدید را برمیگرداند**؛ شبکهٔ ورودی دستنخورده میماند. رفتار مهمش این است که محتوای قبلی را دور نمیریزد: هر سلولی که مختصاتش در هر دو شبکه معتبر باشد، رنگ و نویسهاش منتقل میشود. یعنی بزرگ کردن شبکه طرح موجود را نگه میدارد و کوچک کردن فقط بخش بیرونافتاده را میاندازد.
- **تابع `cloneGrid(grid)`:** کپی کاملاً مستقلی از شبکه میسازد. منظور از مستقل این است که سلولها هم کپی شوند، نه اینکه آرایهٔ تازه به همان شیءهای قبلی اشاره کند؛ وگرنه تغییر در کپی، اصل را هم خراب میکند. تاریخچهٔ بازگشت روی همین تابع بنا میشود.
- **توابع `toJSON(grid)` و `fromJSON(obj)`:** قرینهٔ هماند و پل میان شبکه و فایل ذخیرهشدهاند. اولی شبکه را به شیء سادهای تبدیل میکند که قابل تبدیل به `JSON` باشد و دومی از روی همان شیء، شبکه را بازمیسازد. تابع `fromJSON` باید در برابر دادهٔ ناقص مقاوم باشد: اگر شیء ورودی سلولهای کمتری داشته باشد یا فیلدی جا افتاده باشد، بهجای خطا باید مقدار پیشفرض بگذارد.
</details>
<details class="violet">
<summary>**پیادهسازی فایل `paint.js`**</summary>
**این ماژول شامل ابزارهای نقاشی است.** همان چیزهایی که در هر نرمافزار پیکسلآرت زیر دست کاربر است. همهٔ این توابع یک قرارداد مشترک دارند که باید از اول رعایتش کنید: شبکه را **در جای خود** تغییر میدهند و چیزی که برمیگردانند شبکهٔ جدید نیست، بلکه **فهرست سلولهایی است که واقعاً عوض شدند**، به شکل `[{ row, col }]`. دلیل این طراحی کارایی رسم است؛ رابط کاربری با دیدن این فهرست فقط همان چند سلول را دوباره روی `Canvas` میکشد، نه کل شبکه را.
از این قرارداد یک نکتهٔ مهم نتیجه میشود که سیستم داوری کوئرا روی آن **حساس** است: سلولی که رنگش عوض **نشده** نباید در خروجی بیاید. رنگزدن سلولی که از قبل همان رنگ را دارد و همچنین هر مختصات بیرون شبکه، باید فهرست خالی بدهد. در قسمت زیر تعاریف و نحوه پیادهسازی هر کدام از توابع این ماژول بررسی میشوند:
- **تابع `brush(grid, row, col, color)`:** سادهترین ابزار: یک سلول را رنگ میکند. اگر رنگ سلول واقعاً عوض شود، مختصاتش در فهرست خروجی میآید؛ در غیر این صورت فهرست خالی است.
- **تابع `eraser(grid, row, col)`:** قرینهٔ `brush` است و یک سلول را به حالت خالی برمیگرداند. همان قاعده اینجا هم برقرار است: پاک کردن سلولی که از قبل خالی بوده، تغییری نیست و فهرست خالی میدهد.
- **تابع `mirrorCol(grid, col)`:** شمارهٔ ستون قرینه را نسبت به محور عمودی وسط شبکه برمیگرداند. یعنی ستون اول به ستون آخر نگاشت میشود و ستون وسط در شبکهای با تعداد ستون فرد، قرینهٔ خودش است. این تابع شبکه را تغییر نمیدهد و فقط یک عدد برمیگرداند.
- **تابع `brushSymmetric(grid, row, col, color)`:** حالت قرینهای که تیفوسازها زیاد استفاده میکنند: همزمان سلول اصلی و قرینهٔ افقیاش را رنگ میکند تا طرح متقارن بماند. خروجیاش فهرست همهٔ سلولهایی است که عوض شدند؛ وقتی سلول روی محور تقارن باشد، سلول اصلی و قرینه یکیاند و طبیعتاً فقط یک مختصات در فهرست میآید.
- **تابع `bucket(grid, row, col, color)`:** همان ابزار سطل رنگ است: از سلولی که کلیک شده شروع میکند و ناحیهٔ پیوستهٔ همرنگ را با رنگ تازه پر میکند و تا جایی پیش میرود که به رنگ متفاوت یا مرز شبکه برسد. تعریف «همسایه» اینجا فقط چهار جهت اصلی است: بالا، پایین، چپ و راست. سلولهای قطری همسایه حساب نمیشوند. برای نمونه اگر در یک شبکهٔ ۳×۳ ستون وسط را رنگ کرده باشید و بعد سطل را روی سلولِ `(0, 0)` بزنید، فقط سه سلول ستون چپ پر میشوند، چون ستون رنگشدهٔ وسط دیوار است. اگر رنگ تازه با رنگ فعلی همان سلول یکی باشد، هیچ اتفاقی نمیافتد.
- **تابع `line(grid, r0, c0, r1, c1, color)`:** خط مستقیمی بین دو سلول میکشد. چون شبکه گسسته است، باید مشخص باشد خط دقیقاً از کدام سلولها میگذرد و همهٔ پیادهسازیها به یک جواب برسند؛ به همین دلیل از الگوریتم استاندارد `Bresenham` استفاده کنید. **هر دو سر خط** هم رنگ میشوند، یعنی سلول شروع و سلول پایان جزو خطاند.
- **تابع `rect(grid, r0, c0, r1, c1, color)`:** مستطیل توپر میکشد. دو گوشهٔ دادهشده **داخل** مستطیل حساب میشوند، پس مستطیلی که از سطر `0` تا `1` و ستون `0` تا `2` کشیده شود، شش سلول دارد. ترتیب گوشهها اهمیتی ندارد و باید برای هر ترتیبی نتیجهٔ یکسان بدهد.
```js
const g = createGrid(5, 5);
brush(g, 2, 2, "#fff") // [{ row: 2, col: 2 }]
mirrorCol(createGrid(3, 7), 0) // 6
mirrorCol(createGrid(3, 7), 3) // 3
line(g, 0, 0, 4, 4, "#fff").length // 5 (diagonal)
rect(g, 0, 0, 1, 2, "#fff").length // 6 (2 rows x 3 cols)
bucket(createGrid(4, 4), 0, 0, "#fff").length // 16
bucket(createGrid(3, 3), 0, 0, null).length // 0 (same color)
```
> مثالهای بالا رفتار مورد انتظار ابزارهای رسم را نشان میدهند. توابع این ماژول شبکه را **در جای خود** تغییر میدهند و کپی تازه نمیسازند؛ چیزی که برمیگردانند فهرست سلولهای تغییرکرده است، نه شبکهٔ جدید. همین فهرست به رندر میگوید فقط کدام سلولها را دوباره بکشد.
>
> به حالتهای مرزی دقت کنید: رنگزدن سلولی که از قبل همان رنگ را دارد نباید چیزی در خروجی بگذارد و مختصات بیرون شبکه هم باید بیاثر باشد. ابزار سطل تا مرز رنگ متفاوت پیش میرود و فقط چهار جهت اصلی را دنبال میکند.
</details>
<details class="orange">
<summary>**پیادهسازی فایل `palette.js`**</summary>
**پالت** همان نوار رنگی است که کاربر از میانش رنگ فعال را انتخاب میکند و میتواند رنگ سفارشی هم به آن اضافه کند. کل حالتش دو چیز است: فهرست رنگها و اینکه کدامشان الان فعال است.
بخش پرنکتهٔ این ماژول **اعتبارسنجی رنگ** است. کاربر میتواند هر متنی در جعبهٔ رنگ سفارشی بنویسد، پس باید ورودی را پیش از اضافه کردن هم **بررسی** و هم **یکدست** کنید. در قسمت زیر تعاریف و نحوه پیادهسازی هر کدام از توابع این ماژول بررسی میشوند:
- **تابع `createPalette(swatches = DEFAULT_SWATCHES)`:** پالت تازهای میسازد و برمیگرداند. اگر فهرست رنگ ندهید، از `DEFAULT_SWATCHES` استفاده میشود که خودتان تعریفش میکنید و باید دستکم چهار رنگ داشته باشد. رنگ فعال در ابتدا اولین رنگ فهرست است.
- **تابع `activeColor(palette)`:** رنگ انتخابشدهٔ فعلی را برمیگرداند. ابزارهای نقاشی رنگشان را از همینجا میگیرند.
- **تابع `selectIndex(palette, index)`:** رنگ فعال را با شمارهٔ جایگاهش عوض میکند. اندیس بیرون بازه خطا نمیدهد و به نزدیکترین جایگاه مجاز محدود میشود، پس عدد منفی به رنگ اول و عدد بزرگتر از طول پالت به رنگ آخر میرسد.
- **تابع `normalizeHex(value)`:** رنگ ورودی را به یک شکل استاندارد درمیآورد: **ششرقمی و با حروف کوچک**. قالب کوتاه سهرقمی هم پذیرفته میشود و به شکل کامل باز میشود. فقط دو قالب `#RGB` و `#RRGGBB` معتبرند؛ نام رنگهای `CSS` و قالب `rgb()` نامعتبرند و برای هر ورودی نامعتبر باید `null` برگردد، نه خطا و نه رشتهٔ خالی. این تابع دروازهٔ ورود هر رنگ سفارشی به پالت است.
- **تابع `addCustom(palette, value)`:** رنگ تازهای به پالت اضافه میکند و همان را رنگ فعال میکند و رنگ استانداردشده را برمیگرداند. سه رفتار دارد که سیستم داوری میسنجد: ورودی نامعتبر اضافه نمیشود و `null` برمیگردد؛ رنگی که از قبل در پالت هست دوباره اضافه نمیشود؛ و مقایسهٔ تکراری بودن روی شکل **استانداردشدهٔ** رنگ انجام میشود، پس `#ABCDEF` و `#abcdef` یک رنگاند.
- **تابع `count(palette)`:** تعداد رنگهای پالت را برمیگرداند.
```js
normalizeHex("#ABCDEF") // "#abcdef"
normalizeHex("#0f8") // "#00ff88"
normalizeHex("not-a-color") // null
const p = createPalette(["#aaa"]);
addCustom(p, "#123456") // "#123456"
addCustom(p, "xyz") // null
```
> بلوک بالا نشان میدهد رنگ ورودی چطور یکدست میشود. خروجی همیشه ششرقمی و با حروف کوچک است، پس `#ABCDEF` به `#abcdef` تبدیل میشود و قالب کوتاه `#0f8` به شکل کامل باز میشود.
>
> فقط قالبهای `#RGB` و `#RRGGBB` معتبرند؛ نام رنگ و `rgb()` پذیرفته نمیشوند و برای ورودی نامعتبر باید `null` برگردد. این تابع پیش از افزودن هر رنگ سفارشی به پالت صدا زده میشود.
</details>
<details class="teal">
<summary>**پیادهسازی فایل `font.js` و `layers.js`**</summary>
هر نویسه با یک ماتریس **۵ ردیف در ۳ ستون** از سلولهای روشن و خاموش نوشته میشود:
```js
export const GLYPH_W = 3;
export const GLYPH_H = 5;
```
> این ثابتها ابعاد ماتریس هر نویسهاند. هر حرف روی شبکهای با همین عرض و ارتفاع از خانههای روشن و خاموش نوشته میشود و متن، از کنار هم چیدن همین ماتریسها ساخته میشود.
>
> نویسههای ناشناخته باید خالی رندر شوند و برنامه نباید خطا بدهد. فاصلهٔ میان نویسهها در ارزیابی سنجیده نمیشود، ولی عرض خروجی باید با طول متن بیشتر شود.
روی شبکه فونتی در کار نیست؛ حرفها را باید خودتان با روشن و خاموش کردن خانهها بکشید، دقیقاً همان کاری که تماشاگران روی سکو با بالا بردن مقواهای رنگی میکنند. در قسمت زیر تعاریف و نحوه پیادهسازی هر کدام از توابع این ماژول بررسی میشوند:
- **تابع `glyph(ch)`:** ماتریس یک نویسه را برمیگرداند: جدولی با ابعاد `GLYPH_H` در `GLYPH_W` که مشخص میکند کدام خانهها روشناند. حروف بزرگ انگلیسی و ارقام را باید پوشش بدهید. نویسهای که در فهرست شما نیست نباید خطا بدهد؛ برایش ماتریس کاملاً خالی برمیگردد تا برنامه با یک نویسهٔ ناشناخته از کار نیفتد.
- **تابع `renderText(text)`:** کل یک متن را رندر میکند: نویسهها را از چپ به راست با فاصله کنار هم میچیند و نتیجه را به شکل شیئی با فهرست سلولهای روشن و `width` و `height` برمیگرداند. پیش از رسم، حروف به حرف بزرگ تبدیل میشوند، پس `a` و `A` خروجی یکسان میدهند. مقدار `height` همیشه برابر ارتفاع یک نویسه است و `width` با طولانیتر شدن متن بیشتر میشود.
لایه چیزی است که **روی** شبکهٔ پایه مینشیند بدون اینکه آن را خراب کند؛ میشود جابهجا، پنهان یا حذفش کرد و شبکهٔ زیرش دستنخورده میماند. هر لایه مختصات سلولهایش را **نسبی** نگه میدارد، یعنی نسبت به گوشهٔ بالا-چپ خودش و موقعیت خودش را در `row` و `col` دارد. همین باعث میشود جابهجا کردن لایه فقط تغییر دو عدد باشد.
|  |
| :-: |
| طرح پس از افزودن لایههای متن و شکل |
- **توابع `textLayer(text, row, col, color)` و `shapeLayer(shape, row, col, w, h, color)`:** این دو سازندهٔ لایهاند و هر کدام یک شیء لایه برمیگردانند با شناسهٔ یکتا، نوع، موقعیت، رنگ، فهرست سلولهای نسبی، ابعاد و فیلد `visible` که در ابتدا `true` است. اولی از دل `renderText` میآید و لایهٔ متنی میسازد. دومی لایهٔ شکل میسازد و مقدار `shape` آن یا `"rect"` است یعنی مستطیل توپر، یا `"border"` یعنی فقط قاب بیرونی. برای نمونه یک `border` با ابعاد ۴×۴ دوازده سلول دارد، چون چهار سلول میانی خالی میمانند.
- **توابع `createStack()` و `addLayer(stack, layer)`:** اولی پشتهٔ خالی لایهها را میسازد. دومی لایه را به پشته اضافه میکند و **خود لایه** را برمیگرداند، نه پشته را؛ اینطور میشود بلافاصله شناسهٔ لایهٔ تازه را گرفت و از آن استفاده کرد.
- **تابع `_resetIds()`:** شمارندهٔ شناسهٔ لایهها را از نو شروع میکند تا شناسهها تکرارپذیر بمانند. در رابط کاربری به کار نمیآید.
- **توابع `removeLayer(stack, id)` و `findLayer(stack, id)`:** اولی لایه را از همان پشته حذف میکند و مقدار بولی میدهد که آیا حذف انجام شد. دومی لایه را با شناسهاش پیدا میکند و برای شناسهٔ ناشناخته `null` میدهد. هر دو با شناسهٔ ناشناخته خطا نمیدهند.
- **توابع `moveLayer(stack, id, row, col)` و `setVisible(stack, id, visible)`:** لایه را در همان پشته جابهجا یا پنهان و آشکار میکنند و مقدار بولی موفقیت برمیگردانند. چون مختصات سلولها نسبی است، جابهجا کردن لایه فقط `row` و `col` خودش را عوض میکند.
- **توابع `zIndexOf(stack, id)` و `raise(stack, id)` و `lower(stack, id)`:** این سه ترتیب رویهمافتادن لایهها را مدیریت میکنند. `zIndexOf` جایگاه لایه در پشته را میدهد؛ جایگاه `0` یعنی پایینترین لایه و جایگاه بزرگتر یعنی بالاتر و نزدیکتر به بیننده. دو تابع دیگر لایه را یک پله بالا یا پایین میبرند و مقدار بولی برمیگردانند. اگر لایه از قبل در بالاترین یا پایینترین جایگاه باشد، هیچ کاری انجام نمیشود و `false` برمیگردد؛ در صورت قرار داشتن لایه در ابتدا یا انتهای پشته، ترتیب لایهها از ابتدا تکرار نمیشود.
- **تابع `absoluteCells(layer)`:** مختصات نسبی سلولهای لایه را با موقعیت خود لایه جمع میکند و مختصات **مطلق** آنها روی شبکه را برمیگرداند. این تابع پل میان مختصات محلی لایه و مختصات شبکه است.
- **تابع `composite(baseGrid, stack)`:** تصویر نهایی را میسازد: شبکهٔ پایه بهعلاوهٔ همهٔ لایهها. این تنها تابع ماژول است که شبکهٔ ورودی را دست نمیزند و **شبکهٔ تازه** برمیگرداند. چهار قاعده دارد:
+ شبکهٔ پایه دستنخورده میماند و خروجی یک شبکهٔ مستقل تازه است.
+ لایهها از پایین به بالا کشیده میشوند، پس لایهٔ بالاتر رنگ لایهٔ پایینتر را میپوشاند.
+ لایهای که `visible` آن `false` است اصلاً کشیده نمیشود.
+ سلولهایی از شبکهٔ پایه که هیچ لایهای رویشان نیفتاده، رنگ خودشان را نگه میدارند.
+ سلولهای لایه که بیرون مرز شبکه میافتند نادیده گرفته میشوند و خطا نمیدهند.
</details>
<details class="purple">
<summary>**پیادهسازی فایل `select.js`**</summary>
**انتخاب همان کادر نقطهچینی است** که کاربر دور بخشی از طرح میکشد تا آن تکه را یکجا رنگ یا جابهجا کند. خود انتخاب فقط یک مستطیل است و به شکل `{ top, left, bottom, right }` نگه داشته میشود؛ سلولهای داخلش هرجا لازم شد از روی همین چهار عدد حساب میشوند.
یک قاعده در کل این ماژول برقرار است: **هر چهار مرز داخل انتخاب حساب میشوند.** یعنی انتخابی از سطر `0` تا `1` دو سطر دارد، نه یک سطر. در قسمت زیر تعاریف و نحوه پیادهسازی هر کدام از توابع این ماژول بررسی میشوند:
- **تابع `marquee(r0, c0, r1, c1)`:** از دو گوشهای که کاربر با کشیدن ماوس مشخص کرده یک مستطیل مرتب میسازد. کاربر میتواند کادر را از هر جهتی بکشد، پس ورودی میتواند جابهجا باشد؛ خروجی همیشه مستطیلی است که `top` از `bottom` کوچکتر و `left` از `right` کوچکتر است.
- **توابع `cellsIn(sel)` و `contains(sel, row, col)` و `size(sel)`:** این سه تابع اطلاعات هندسی انتخاب را برمیگردانند. اولی فهرست همهٔ سلولهای داخل انتخاب را میدهد، دومی میگوید یک سلول مشخص داخل انتخاب هست یا نه و مقدار بولی برمیگرداند و سومی ابعاد انتخاب را به شکل `{ width, height }` میدهد. چون مرزها داخل حساب میشوند، انتخابی از ستون `0` تا `3` عرض `4` دارد نه `3`.
- **تابع `snapshot(grid, sel)`:** از محتوای داخل انتخاب یک کپی میگیرد. مختصات این کپی **نسبی** است، یعنی نسبت به گوشهٔ بالا-چپ خود انتخاب و به شکل `[{ dr, dc, color }]` برمیگردد. همین نسبی بودن است که اجازه میدهد بعداً محتوا را در جای دیگری بنشانید بدون اینکه چیدمان داخلیاش به هم بخورد.
- **تابع `moveSelection(grid, sel, dRow, dCol)`:** محتوای انتخاب را بهاندازهٔ دادهشده جابهجا میکند و **مستطیل انتخاب جدید** را برمیگرداند، نه شبکه را. چند نکته وجود دارد که سیستم داوری کوئرا همین موارد را بررسی میکند:
+ از آنجایی که این عمل جابهجایی است و نه کپی کردن، جای قبلی خالی میشود.
+ چیدمان نسبی سلولها حفظ میشود؛ اگر دو سلول یک واحد فاصله داشتند، بعد از جابهجایی هم همان فاصله را دارند.
+ بخشی از انتخاب که بیرون مرز شبکه بیفتد از بین میرود و بقیه سر جای تازهشان مینشینند. اگر کل انتخاب بیرون برود، محتوایش کاملاً از دست میرود.
توجه کنید که مبدأ و مقصد میتوانند همپوشانی داشته باشند؛ اگر جابهجایی را سلولبهسلول و بدون کپی گرفتن انجام بدهید، داده خراب میشود.
- **تابع `fillSelection(grid, sel, color)`:** همهٔ سلولهای داخل انتخاب را با یک رنگ پر میکند و مثل ابزارهای `paint.js` فهرست سلولهای تغییرکرده را برمیگرداند.
```js
size(marquee(0, 0, 2, 3)) // { width: 4, height: 3 }
moveSelection(g, marquee(0, 0, 1, 1), 2, 1)
// { top: 2, left: 1, bottom: 3, right: 2 }
```
> بلوک بالا رفتار ناحیهٔ انتخاب را نشان میدهد. تابع `marquee` از دو نقطه یک مستطیل میسازد و چون هر دو مرز شامل خودشاناند، بازهٔ `0` تا `3` عرض `4` میدهد نه `3`.
>
> تابع `moveSelection` محتوای انتخاب را جابهجا میکند و جای قبلی را خالی میگذارد. اگر بخشی از انتخاب بیرون شبکه برود همان بخش حذف میشود و پیش از پاککردن مبدأ باید یک کپی از محتوا بگیرید تا جابهجاییهای همپوشان داده را خراب نکنند.
</details>
<details class="yellow">
<summary>**پیادهسازی فایل `reveal.js` و `playback.js`**</summary>
**«نمایش تدریجی»** همان افکتی است که تیفو با آن روی سکو باز میشود: سلولها یکباره ظاهر نمیشوند، بلکه به ترتیبی مشخص یکییکی روشن میشوند. این قابلیت از دو بخش تشکیل شده که در دو فایل جداگانه پیادهسازی میشوند. `reveal.js` میگوید سلولها **به چه ترتیبی** روشن شوند و `playback.js` میگوید نوار پیشرفت **چطور جلو برود**.
ترتیبهای نمایش هر کدام یک آرایه از مختصات سلولها برمیگردانند، از اولین سلولی که روشن میشود تا آخرین:
- **تابع `orderRows(grid)`:** سلولها را سطربهسطر میچیند و از سلولِ `(0, 0)` شروع میکند.
- **تابع `orderCols(grid)`:** ستونبهستون میچیند، پس در یک شبکهٔ ۲×۲ عضو دوم سلولِ `(1, 0)` است نه `(0, 1)`.
- **تابع `orderWave(grid)`:** موج قطری میسازد: سلولها بر اساس فاصلهشان از گوشهٔ بالا-چپ گروهبندی میشوند، پس ترتیب از `(0, 0)` شروع و به گوشهٔ مقابل ختم میشود.
- **تابع `orderSpiral(grid)`:** ترتیب مارپیچی از مرکز به بیرون میدهد؛ در شبکهٔ ۵×۵ عضو اول سلولِ `(2, 2)` است. وقتی تعداد سطر یا ستون زوج باشد شبکه مرکز دقیق ندارد؛ در آن حالت سلول **بالاتر و چپتر** را مرکز بگیرید تا خروجی یکتا بماند.
- **تابع `modes()`:** فهرست نام حالتها را برمیگرداند و باید دقیقاً این چهار نام باشد: `rows` و `cols` و `wave` و `spiral`.
- **تابع `buildReveal(grid, mode = "rows")`:** ترتیب انتخابشده را میسازد و به هر سلول یک `threshold` بین `0` و `1` میچسباند؛ یعنی «این سلول از چه لحظهای از نمایش به بعد روشن است». اولین سلول `threshold` برابر `0` و آخرین سلول `1` میگیرد و بقیه بهطور یکنواخت میان این دو پخش میشوند. اگر `mode` داده نشود، ترتیب سطری استفاده میشود.
- **توابع `revealedAt(revealList, progress)` و `revealedCount(revealList, progress)`:** میگویند در یک لحظهٔ مشخص از نمایش، کدام سلولها روشناند؛ اولی خود سلولها را میدهد و دومی فقط تعدادشان را. قاعده این است که سلولی روشن است که `threshold` آن از `progress` بیشتر **نباشد**. یک نتیجهٔ مهم از این قاعده بیرون میآید که سیستم داوری میسنجد: چون `threshold` اولین سلول برابر `0` است، در `progress` برابر صفر دقیقاً **یک** سلول روشن است، نه هیچ سلولی؛ و در `progress` برابر یک، همهٔ سلولها روشناند.
|  |
| :-: |
| شبکه در میانهٔ نمایش تدریجی طرح |
در ادامه، کنترل پخش را پیادهسازی میکنیم. حالت پخش شیئی است که پیشرفت فعلی، وضعیت پخش و سرعت را نگه میدارد:
- **تابع `createPlayback()`:** وضعیت اولیه را میسازد: `progress` برابر `0` و `playing` برابر `false`.
- **تابع `seek(pb, progress)`:** پیشرفت را روی مقدار دادهشده میگذارد و آن را بین `0` و `1` مهار میکند.
- **توابع `play(pb)` و `pause(pb)` و `toggle(pb)`:** پخش را روشن، خاموش یا برعکس میکنند. `play` یک رفتار ویژه دارد: اگر نمایش از قبل تا انتها رفته باشد، بهجای اینکه در همان انتها گیر کند، از ابتدا شروع میکند.
- **تابع `setSpeed(pb, speed)`:** سرعت پخش را میگذارد و آن را داخل بازهٔ مجاز نگه میدارد؛ بیشینهٔ مجاز `4` است.
- **تابع `tick(pb, dt)`:** پیشرفت را بهاندازهٔ زمان سپریشده جلو میبرد و در هر فریم صدا زده میشود. در حالت توقف هیچ کاری نمیکند. وقتی پیشرفت به `1` میرسد همانجا میایستد و `playing` خودبهخود `false` میشود، یعنی از عدد یک عبور نمیکند.
- **تابع `frameIndex(pb, frames)`:** پیشرفت را به شمارهٔ فریم تبدیل میکند. خروجی همیشه عددی صحیح در بازهٔ `0` تا `frames - 1` است؛ برای پیشرفت `0.5` و یازده فریم نتیجه `5` است و اگر `frames` صفر باشد، مقدار `0` برمیگردد.
- **تابع `atEnd(pb)`:** میگوید نمایش به انتها رسیده است یا نه و مقدار بولی برمیگرداند.
</details>
<details class="olive">
<summary>**پیادهسازی فایل `templates.js` و `history.js` و `io.js`**</summary>
قالبها طرحهای آمادهایاند که کاربر با یک کلیک روی شبکه مینشاند تا از صفر شروع نکند. سه تابع اول روی شبکهای که به آنها میدهید کار میکنند و **همان شبکه** را برمیگردانند. در قسمت زیر تعاریف و نحوه پیادهسازی هر کدام از توابع این ماژول بررسی میشوند:
- **تابع `tricolor(grid, colors)`:** پرچم سهنواره میسازد: سه نوار **افقی** همارتفاع با سه رنگ دادهشده. تعداد سطرها همیشه بر سه بخشپذیر نیست، پس باید تکلیف سطرهای اضافه روشن باشد: سطرهای اضافه به نوارهای **بالایی** میرسند.
- **تابع `verticalBands(grid, colors)`:** همان کار را با سه نوار **عمودی** همعرض انجام میدهد و ستونهای اضافه به نوارهای سمت **چپ** میرسند.
- **تابع `word(grid, text, fg, bg)`:** کل شبکه را با رنگ پسزمینه پر میکند و بعد متن را وسطچین با رنگ پیشزمینه رویش مینویسد. وقتی فضای باقیماندهٔ دو طرف برابر نمیشود، متن را به سمت **بالا و چپ** بچسبانید تا خروجی یکتا و قابلارزیابی بماند.
- **تابع `applyPreset(name, rows, cols)`:** با نام یک قالب و ابعاد خواستهشده، شبکهٔ **تازهای** میسازد و قالب را رویش اعمال میکند. برای نام ناشناخته `null` برمیگرداند، نه شبکهٔ خالی. یک شرط مهم دارد: بعد از اعمال هر قالب، کل شبکه باید رنگشده باشد، یعنی `paintedCount` با `cellCount` برابر شود و هیچ سلول خالی نماند.
- **تابع `presetNames()`:** فهرست نام قالبهای موجود را برمیگرداند و باید دقیقاً شامل این سه نام باشد: `flag-tricolor` و `word-goal` و `word-2026`.
**تاریخچه** امکان پیادهسازی عملیات *Undo* و *Redo* را فراهم میکند. مدلش یک فهرست از وضعیتها است بهعلاوهٔ یک نشانگر که میگوید الان روی کدام وضعیت ایستادهاید؛ بازگشت یعنی نشانگر یک قدم عقب برود، نه اینکه چیزی از فهرست حذف شود.
+ `createHistory(initial)` تاریخچهٔ تازهای با یک وضعیت اولیه میسازد و برمیگرداند.
+ `push(history, state)` وضعیت تازهای را ثبت میکند.
+ `undo(history)` و `redo(history)` یک قدم عقب و جلو میروند و وضعیت جدید را برمیگردانند. اگر قدمی برای رفتن نمانده باشد، `null` میدهند.
+ `canUndo(history)` و `canRedo(history)` میگویند قدمی برای عقب یا جلو رفتن مانده است یا نه و مقدار **بولی** برمیگردانند.
+ `current(history)` وضعیت فعلی را برمیگرداند.
+ `depth(history)` تعداد کل وضعیتهای ثبتشده **از جمله وضعیت اولیه** را میدهد؛ یعنی بلافاصله بعد از `createHistory` برابر `1` است.
+ `push` بعد از یک `undo`، شاخهٔ `redo` را پاک میکند.
+ **نکته:** وضعیتهای ذخیرهشده باید کپی مستقل باشند. اگر شیئی را ثبت کنید و بعد همان شیء را تغییر بدهید، مقدار داخل تاریخچه نباید عوض شود؛ وگرنه بازگشت به وضعیت قبلی همان وضعیت فعلی را برمیگرداند و دکمهٔ `undo` عملاً بیاثر میشود.
ماژول ورودی و خروجی، طرح را به فایل تبدیل میکند و برعکس. سندی که ذخیره میشود شامل شبکه، لایهها و پالت است.
```js
export const SCHEMA_VERSION = 1;
```
> شمارهٔ نسخهٔ سند در فایل ذخیرهشده میآید تا اگر بعداً شکل داده عوض شد، بشود نسخهٔ قدیمی را تشخیص داد.
>
> هنگام بازیابی، سندی که این کلید را نداشته باشد یا ساختارش ناقص باشد نامعتبر است و برنامه باید با وضعیت اولیه بالا بیاید، نه اینکه خطا بدهد.
- **تابع `serialize(doc)`:** سند را به متن `JSON` تبدیل میکند و فیلد `version` را با مقدار `SCHEMA_VERSION` داخلش میگذارد. خروجی یک **رشته** است، نه شیء.
- **تابع `deserialize(text)`:** قرینهٔ تابع قبلی است: متن را به سند برمیگرداند. برای متنی که `JSON` معتبر نیست یا ساختار موردانتظار را ندارد، بهجای پرتاب خطا مقدار `null` برمیگرداند. این رفتار ضروری است، چون کاربر میتواند هر فایلی را روی برنامه بیندازد و برنامه نباید با یک فایل خراب از کار بیفتد.
- **تابع `isValidDocument(text)`:** بررسی میکند متن دادهشده هم `JSON` معتبر باشد و هم هر سه کلید `grid` و `layers` و `palette` را داشته باشد. نبودن هر کدام از این سه، سند را نامعتبر میکند. خروجی بولی است.
- **تابع `toBlob(doc)`:** همان متن `JSON` را داخل یک `Blob` با نوع `application/json` میگذارد تا دکمهٔ دانلود بتواند از رویش فایل بسازد.
- **تابع `exportPNG(canvas)`:** از `Canvas` یک خروجی تصویری به شکل `data:image/png` میسازد و برمیگرداند. برای ورودی نامعتبر مقدار `null` میدهد.
</details>
<details class="pink">
<summary>**رابط کاربری و فایل `main.js`**</summary>
فایل `main.js` باید دو شیء در دسترس بگذارد:
+ `window.studioApi` که ماژولها را با نام خودشان بیرون میدهد: `grid`، `paint`، `palette`، `layers`، `select`، `reveal`، `playback`، `templates`، `history`، `io`.
+ `window.studio` که وضعیت برنامه و چند متد کمکی دارد.
+ **قرارداد سیستم داوری:** این دو شیء فقط برای داوری لازماند؛ کاربر نهایی با آنها کاری ندارد.
شیء `window.studio` باید `model` داشته باشد با این فیلدها:
+ `grid` شبکهٔ رنگها
+ `stack` پشتهٔ لایهها، که `stack.layers` آرایهٔ لایههاست
+ `palette` که `palette.swatches` آرایهٔ رنگها و `palette.activeIndex` اندیس رنگ فعال است
+ `tool` و `symmetry` و `revealMode` و `revealList` و `playback`
|  |
| :-: |
| چیدمان کامل استودیو با پالت و پنل لایهها |
و این متدها را داشته باشد:
+ `paintCell(row, col, opts?)` که برای ابزار `line` با گزینهٔ `{ from: { row, col } }` نقطهٔ شروع خط را میگیرد
+ `eventToCell({ clientX, clientY })` مختصات صفحه را به سلولِ شبکه تبدیل میکند؛ گوشهٔ بالا-چپ `Canvas` باید به سلولِ `(0, 0)` نگاشت شود
+ `setTool(name)` ابزار فعال را عوض میکند، `toggleSymmetry()` حالت قرینه را روشن و خاموش میکند و `setColorIndex(i)` رنگ فعال پالت را انتخاب میکند.
+ `activeColor()` رنگ فعال را برمیگرداند و `addCustomColor(hex)` رنگ تازهای به پالت اضافه میکند.
+ `addTextLayer(text)` لایهٔ متنی تازهای میسازد و `compositeGrid()` شبکهٔ نهایی (پایه بهعلاوهٔ لایهها) را برمیگرداند.
+ `applyTemplate(name)` یک قالب آماده را روی شبکه مینشاند و `clearGrid()` کل شبکه را خالی میکند.
+ `undo()` و `redo()` یک قدم عقب و جلو میروند و `canUndo()` میگوید قدمی برای بازگشت مانده است یا نه.
+ `setRevealMode(mode)` حالت نمایش تدریجی را عوض میکند و `seekReveal(progress)` پیشرفت را میگذارد.
+ `revealProgress()` پیشرفت فعلی را بهصورت عددی بین `0` و `1` و `revealedCount()` تعداد سلولهای آشکارشده را برمیگردانند.
+ `exportJSON()` و `importJSON(text)` که برای متن نامعتبر `false` برمیگرداند
شبکهٔ اولیه باید دستکم ۱۰۱ سلول داشته باشد؛ پیشنهاد ما ۲۴ سطر در ۴۰ ستون است.
**تمام عنصرهای زیر از قبل در `index.html` هستند** و در `main.js` هم شیء `el` به همهشان اشاره میکند. کار شما ساختن آنها نیست؛ کار شما **بهروز نگهداشتن وضعیتشان از روی مدل** و **پر کردن ظرفهای خالی** است.
|  |
| :-: |
| نواحی اصلی صفحه و نام هرکدام |
**سه ظرف خالی که باید پرشان کنید:**
+ `palette` برای هر رنگ یک `swatch-<index>` میگیرد که `role="option"` و `aria-label` دارد؛ رنگ فعال `aria-selected="true"` و بقیه `"false"`.
+ `layer-list` برای هر لایه یک `layer-item-<id>` میگیرد و لایهٔ فعال `aria-current="true"` میشود. کنار هر لایه سه دکمهٔ `layer-remove-<id>` و `layer-raise-<id>` و `layer-visible-<id>` قرار میگیرد.
+ `template-buttons` برای هر قالب یک دکمهٔ `template-<name>` میگیرد و متن هر دکمه شامل نام همان قالب است.
**وضعیتهایی که باید با مدل همگام بمانند:**
+ پنج ابزار `tool-brush` و `tool-eraser` و `tool-bucket` و `tool-line` و `tool-select` از قبل `role="radio"` دارند؛ شما فقط `aria-checked` را جابهجا میکنید تا در هر لحظه دقیقاً یکی انتخابشده باشد. ترتیب `Tab` هم باید فقط روی ابزار انتخابشده بیفتد (`tabindex="0"` و بقیه `-1`) و کلیدهای جهتدار انتخاب را جابهجا کنند.
+ دکمهٔ `toggle-symmetry` حالت قرینه را عوض میکند و `aria-pressed` میگیرد.
+ دکمههای `undo` و `redo` باید بر اساس وضعیت تاریخچه فعال و غیرفعال شوند.
+ چهار گزینهٔ `reveal-rows` و `reveal-cols` و `reveal-wave` و `reveal-spiral` هم `aria-checked` خودشان را از حالت نمایش فعلی میگیرند.
+ دکمهٔ `play-reveal` مقدار `aria-pressed` میگیرد و اسلایدر `reveal-scrubber` از نوع `range` با `min="0"` و `max="100"` است، پس مقدار `50` یعنی پیشرفت `0.5`. متن `reveal-progress` درصد را با عدد نشان میدهد و در پیشرفت کامل باید `100` داخلش باشد.
**کلید میانبر:** کلید `b` در سطح `document` ابزار را به قلم برمیگرداند، ولی فقط وقتی تمرکز روی یک فیلد متنی یا عنصر قابل ویرایش نباشد؛ وگرنه تایپ حرف `b` داخل ورودی رنگ سفارشی، ابزار را عوض میکند.
**رنگ سفارشی:** ورودی `custom-color-input` یک **ورودی متنی** است، نه `type="color"`، چون سیستم داوری رشتهٔ `hex` را داخلش تایپ میکند. دکمهٔ `custom-color-add` رنگ را به پالت اضافه میکند.
**خروجی:** متن ناحیهٔ `io-status` بعد از هر کنش باید کلمهٔ کلیدی مربوطه را داشته باشد: بعد از خروجی `JSON` کلمهٔ `Saved`، بعد از خروجی تصویر کلمهٔ `PNG` و وقتی رنگ سفارشی نامعتبر است کلمهٔ `Invalid`.
**ذخیرهسازی:** وضعیت باید در `localStorage` زیر کلید دقیق `tifo-designer` ذخیره شود و بعد از باز شدن دوبارهٔ صفحه برگردد. اگر مقدار ذخیرهشده وجود نداشت، `JSON` معتبر نبود یا ساختارش با سند سازگار نبود، برنامه باید با وضعیت اولیه بالا بیاید و خطا پرتاب نکند.
</details>
# **آنچه سیستم داوری بررسی میکنند**
**سیستم داوری** ماژولها را از `window.studioApi` میگیرد و مستقیم صدا میزند و برای رابط کاربری با `window.studio` کار میکند.
+ **نکته:** سیستم داوری رویدادهای واقعی موس را روی `Canvas` شبیهسازی نمیکند، ولی متد `eventToCell({ clientX, clientY })` را مستقیم صدا میزند و انتظار دارد گوشهٔ بالا-چپ `Canvas` به سلولِ `(0, 0)` نگاشت شود. جز این، در نحوهٔ پیادهسازی تعامل با `Canvas` آزادید.
**رنگ، فونت و اندازهٔ پیکسلی بررسی نمیشوند. آنچه بررسی میشود به شکل زیر است:**
+ مقدار بازگشتی هر تابع برای ورودیهای مشخص، دقیقاً مطابق مثالهای بالا
+ تغییر کردن شبکه در توابع `grid` و `paint` و `select` و تغییر **نکردن** شبکهٔ پایه در `composite`
+ مستقل بودن کپیها در `cloneGrid` و `createHistory`
+ وجود عناصر با `data-testid`های گفتهشده و ویژگیهای `role="radiogroup"`، `aria-checked`، `aria-pressed`، `aria-selected`، `aria-current`، `aria-label` و `aria-live`
+ وضعیت `disabled` دکمهٔ `undo` در شروع
+ فیلدهای `window.studio.model` بعد از هر تغییر
+ ذخیره و بازیابی وضعیت در `localStorage`
آنچه بررسی **نمیشود** و میتوانید آزادانه و به دلخواه خودتان پیادهسازی کنید:
+ شکل دقیق ماتریس هر نویسه در `font.js`؛ فقط ارتفاع پنج ردیف، بزرگ شدن عرض با طول متن، خالی بودن نویسهٔ ناشناخته و یکسان بودن نتیجهٔ حروف کوچک و بزرگ سنجیده میشود
+ فاصلهٔ دقیق بین نویسهها
+ رنگهای `DEFAULT_SWATCHES`؛ فقط تعدادشان مهم است
+ ترتیب دقیق سلولها در `orderWave`؛ فقط سلولِ اول و آخر سنجیده میشود
+ طراحی بصری صفحه و ساختار `CSS`
# **آنچه باید آپلود کنید**
- **توجه:** فایل زیپ ارسالی شما باید همان ساختار پروژهٔ اولیه را داشته باشد:
```plaintext
├── scripts/
│ ├── font.js
│ ├── grid.js
│ ├── history.js
│ ├── io.js
│ ├── layers.js
│ ├── main.js
│ ├── paint.js
│ ├── palette.js
│ ├── playback.js
│ ├── reveal.js
│ ├── select.js
│ └── templates.js
├── index.html
└── styles.css
```
> درخت بالا دقیقاً همان چیزی است که باید در آرشیو آپلود کنید. پوشهٔ `scripts/` و دو فایل `index.html` و `styles.css` باید در ریشهٔ فایل زیپ باشند، نه داخل یک پوشهٔ اضافه.
>
> فایل تازهای نسازید؛ ماژولهای اضافه بارگذاری نمیشوند و اگر منطقی را داخلشان بگذارید از دست میرود.
- **توجه:** نام و مسیر فایلها را تغییر ندهید، هر گونه تغییر باعث دریافت نمره صفر از سیستم داوری خواهد شد.
- **توجه:** فایلها باید در **ریشهٔ** فایل زیپ باشند، نه داخل یک پوشهٔ اضافه. اگر همهچیز را داخل پوشهای به اسم `answer` بگذارید، مسیرهای موردانتظار پیدا نمیشوند و داوری اجرا نمیشود.
- **توجه: فایل تازهای نسازید.** سیستم داوری کوئرا فقط فایلهای بالا را در نظر میگیرد، پس اگر ماژول جدیدی بسازید و از جایی `import`اش کنید، آن ماژول در سیستم داوری در نظر گرفته نمیشود و بارگذاری کل صفحه شکست میخورد. کد کمکی را داخل همان فایلهای موجود بنویسید.
استودیو تیفو دیزاینر
**بحث سر اینکه کدام بازیکن بهتر است هیچوقت تمام نمیشود!** چون هر طرف آماری را میگوید که به نفع انتخاب خودش است. ولی وقتی همان عددها را کنار هم بگذارید، بحث از سلیقه بیرون میآید! در هر آمار معلوم میشود کدام بازیکن جلوتر است و اختلافشان چقدر است. در این سوال قرار است همین ابزار داوری را بسازید. **دوین** بین بازیکنها جستوجو میکند، حداکثر تا سه نفر را برای مقایسه انتخاب میکند و آمارشان را در یک **جدول رودررو** و یک **نمودار راداری** کنار هم میبیند. پیادهسازی کل این چالش با `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` دسترسی **نداشته** باشند.
مقایسهگر ابلفضلی دوین
وقتی چند نفر در شهرهای مختلف یک بازی را تماشا میکنند، پخش هرکس **چند ثانیه با بقیه فاصله دارد** و نتیجهاش این است که **یکی گل را زودتر از بقیه لو میدهد!** راهحل یک اتاق تماشای مشترک است که همه در آن یک چیز را همزمان میبینند، با هم چت میکنند، رأی میدهند و برای ادامهٔ بازی پیشبینی میکنند. در این سوال قرار است همین اتاق را بسازید. اتاق شامل چت، نظرسنجی، پیشبینی، جدول امتیاز و واکنشهای شناور است. پیادهسازی کل این چالش با `React` و `TypeScript` است. حجم پیادهسازی این سوال بسیار زیاد است، پس پیش از شروع کل صورت سوال را یک بار بخوانید.

**هدف این سوال پیادهسازی مدیریت وضعیت مشترک در یک برنامهٔ `React` است**: چند بخش که همگی روی یک وضعیت اتاق کار میکنند، بهعلاوهٔ محدودیت نرخ، رتبهبندی، پشتهٔ بازگشت و ماندگاری. این سنگینترین سوال این مسابقه است.
# **پروژهٔ اولیه**
**پروژهٔ اولیه** را از [این لینک](/contest/assignments/103143/download_problem_initial_project/356842/) دانلود کنید. این سوال یک پروژهٔ `Vite` با `React` و `TypeScript` و `Tailwind` است.
<details class="green">
<summary>**ساختار فایلها و پروژه اولیه**</summary>
```plaintext
initial_project/
├─ src/
│ ├─ data/
│ │ └─ <mark class="green" title="پیادهسازی شده؛ آماده است و نباید تغییر کند">room.ts</mark>
│ ├─ engine/
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">room.ts</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">chat.ts</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">polls.ts</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">predictions.ts</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">leaderboard.ts</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">reactions.ts</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">timeline.ts</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">command.ts</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">virtual.ts</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">persistence.ts</mark>
│ │ └─ <mark class="orange" title="باید پیادهسازی شود">format.ts</mark>
│ ├─ hooks/
│ │ └─ <mark class="orange" title="باید پیادهسازی شود">useMatchClock.ts</mark>
│ ├─ context/
│ │ └─ <mark class="orange" title="باید پیادهسازی شود">RoomContext.tsx</mark>
│ ├─ components/
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">ChatPanel.tsx</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">PollPanel.tsx</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">PollCard.tsx</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">PredictionPanel.tsx</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">Leaderboard.tsx</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">PresenceRail.tsx</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">TimelineRibbon.tsx</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">EmojiStorm.tsx</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">Toast.tsx</mark>
│ │ └─ <mark class="orange" title="باید پیادهسازی شود">ThemeToggle.tsx</mark>
│ ├─ pages/
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">RoomPage.tsx</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">PollsPage.tsx</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">PredictionsPage.tsx</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">LeaderboardPage.tsx</mark>
│ │ ├─ <mark class="orange" title="باید پیادهسازی شود">TimelinePage.tsx</mark>
│ │ └─ <mark class="orange" title="باید پیادهسازی شود">SettingsPage.tsx</mark>
│ └─ <mark class="orange" title="باید پیادهسازی شود">App.tsx</mark>
```
+ **نکته:** داخل فایلهای پروژهٔ اولیه، در هر بخش کامنتهایی جهت انجام راهنمایی برای پیادهسازی قرار گرفتهاند: قرارداد هر تابع، حالتهای مرزی و نکتههای پیادهسازی. پیش از شروع هر فایل، کامنتهای بالای آن را بخوانید.
+ **نکته:** درخت بالا ساختار پروژه اولیه است. منطق در `engine/` قرار میگیرد، هوکها وضعیت را نگه میدارند و کامپوننتها فقط نمایش میدهند و نباید هیچ منطقی داشته باشند. اتاق تماشا از چند بخش مستقل ساخته میشود: **چت، نظرسنجی، پیشبینی، واکنشها، خط زمان و جدول امتیاز!**
+ **نکته:** نام فایلها و مسیرشان را تغییر ندهید. **سیستم داوری** این ماژولها را مستقیم `import` میکند و هر مسیر جابهجاشده باعث میشود که **سیستم داوری نمره صفر را لحاظ کند.**
هر فایل شامل تمام `export`های لازم بهصورت `TODO` است. نام فایلها و نام `export`ها را **تغییر ندهید.**
</details>
<details class="green">
<summary>**نکته: آنچه از قبل آماده و در فایل پروژه اولیه قرار گرفته است**</summary>
فایل `src/data/room.ts` اینها را `export` میکند:
+ `Member` و `TimelineEvent` تایپها
+ `MATCH` مشخصات بازی
+ `SEED_MEMBERS` فهرست اعضای اتاق و `ME_ID` شناسهٔ خود کاربر
+ `TIMELINE` سناریوی رویدادهای بازی
+ `REACTION_EMOJIS` فهرست ایموجیهای مجاز
</details>
# **جزئیات پیادهسازی**
برنامه شش صفحه دارد که با روتینگ بینشان جابهجا میشوید:
| **صفحه** | **مسیر** | `data-testid` |
| :-: | :-: | :-: |
| اتاق | `/` | `room-page` |
| نظرسنجیها | `/polls` | `polls-page` |
| پیشبینیها | `/predictions` | `predictions-page` |
| جدول امتیاز | `/leaderboard` | `leaderboard-page` |
| خط زمان | `/timeline` | `timeline-page` |
| تنظیمات | `/settings` | `settings-page` |
+ **نکته:** توابع `engine` باید **خالص** *(Pure)* باشند: به `DOM` دسترسی نداشته باشند (بهجز `persistence.ts` که `Storage` را بهعنوان پارامتر میگیرد) و هیچوقت ورودیشان را **تغییر ندهند.**
|  |
| :-: |
| چت، واکنش و نظرسنجی همزمان در یک اتاق؛ همین جریان را در اجرای واقعی برنامه نشان میدهد |
<details class="green">
<summary>**ترتیب پیشنهادی پیادهسازی**</summary>
1. `format.ts` و `room.ts` کوچکاند و بقیه ماژولها به آنها نیاز دارند.
2. `chat.ts` و `polls.ts` و `reactions.ts` هرکدام مستقلاند.
3. `predictions.ts` و `leaderboard.ts` با هم پیش میروند.
4. `timeline.ts` و `command.ts` و `virtual.ts` و `persistence.ts` کوچکاند.
5. و **آخر** از همه به سراغ پیادهسازی `RoomContext` و کامپوننتها و صفحهها بروید.
</details>
<details class="blue">
<summary>**پیادهسازی `engine/format.ts`**</summary>
این فایل رشتههای آمادهٔ نمایش را میسازد تا کامپوننتها درگیر قالببندی نشوند و شکل اعداد در کل برنامه یکدست بماند. همهٔ این توابع **خالص**اند؛ یعنی فقط از روی ورودیشان رشته میسازند. شش تابع زیر را بنویسید:
+ `formatMinute(minute)` دقیقهٔ بازی را برای نمایش آماده میکند و برای دقیقههای بعد از نود، قالب وقت اضافه میدهد.
+ `formatScore(home, away)` دو عدد گل را به یک نتیجهٔ واحد تبدیل میکند. فاصلههای دو طرف خط تیره بخشی از قالباند.
+ `percent(value)` عدد را به رشتهٔ درصد با عدد صحیح تبدیل میکند.
+ `formatRankDelta(delta)` جابهجایی رتبه را با علامتش نشان میدهد و برای «بدون تغییر» رشتهٔ **خالی** برمیگرداند، نه صفر.
+ `onlineLabel(count)` تعداد کاربران آنلاین را به برچسب نوار بالا تبدیل میکند.
+ `pointsLabel(points)` امتیاز را با واحدش برمیگرداند.
خروجی دقیق هر تابع را از نمونهٔ زیر بردارید؛ سیستم داوری رشته را کاراکتربهکاراکتر میسنجد:
```js
formatMinute(12) // "12'"
formatMinute(93) // "90+3'"
formatScore(2, 1) // "2 - 1"
percent(66.6) // "67%"
formatRankDelta(2) // "+2"
formatRankDelta(-1) // "-1"
onlineLabel(4) // "4 online"
pointsLabel(10) // "10 pts"
```
+ **نکته:** بلوک بالا قالب نمایش دقیقهٔ بازی را نشان میدهد. دقیقه با علامت پرایم نوشته میشود و برای وقت اضافه قالب جداگانه دارد، پس عددهای بالای `90` باید به شکل `"90+n'"` دربیایند.
+ **نکته:** خروجی همیشه رشته است و مستقیم روی صفحه مینشیند، پس فاصله و علامتها هم دقیقاً باید همان باشند.
+ **نکته:** دقیقهٔ بعد از نود به شکل وقت اضافه نوشته میشود، یعنی دقیقهٔ ۹۳ میشود `90+3'`.
**تابع** `tokenizeChat(text)` متن پیام را به آرایهای از توکنها میشکند تا بتوانید منشنها را جدا از متن عادی رندر کنید. هر توکن شکل `{ text: string, mention: boolean }` دارد؛ مثلاً `tokenizeChat("hi @Mina")` سه توکن میدهد که فقط توکن `"@Mina"` مقدار `mention: true` دارد.
</details>
<details class="green">
<summary>**پیادهسازی `engine/room.ts` و `engine/chat.ts`**</summary>
**اتاق تماشا وضعیت حضور کاربران را نگه میدارد:** چه کسانی عضو اتاق هستند، چه کسانی در حال حاضر آنلایناند و میزبان اتاق چه کسی است. سه چیز را از هم جدا نگه دارید، چون هر سه معنای متفاوتی دارند: `members` همهٔ اعضا، `online` فقط شناسهٔ کسانی که آنلایناند و `hostId` شناسهٔ میزبان. در قسمت زیر تعاریف و نحوه پیادهسازی هر کدام از توابع این ماژول بررسی میشوند:
|  |
| :-: |
| اتاق تماشا با فهرست اعضا و جریان چت |
- **تابع `createRoom(members)`:** اتاق تازهای میسازد و شیئی به شکل `{ members, online, hostId }` برمیگرداند. آرایهٔ `online` فقط شناسه نگه میدارد و **بدون تکرار** است؛ یعنی یک عضو نمیتواند دو بار آنلاین باشد.
- **تابع `presenceOrder(state)`:** فهرست اعضا را به همان ترتیبی برمیگرداند که باید در نوار حضور نمایش داده شوند. یک قاعدهٔ ثابت دارد: **میزبان همیشه اول است.**
- **توابع `join(state, id)` و `leave(state, id)`:** عضو را آنلاین یا آفلاین میکنند. آنلاین کردن عضوی که از قبل آنلاین است نباید شناسهاش را دوباره به فهرست اضافه کند و آفلاین کردن عضوی که از قبل آفلاین است هم نباید خطا بدهد.
- **توابع `isOnline(state, id)` و `onlineCount(state)`:** اولی میگوید یک عضو مشخص آنلاین است یا نه و مقدار بولی برمیگرداند؛ دومی تعداد کل اعضای آنلاین را میدهد.
- **توابع `isHost(state, id)` و `transferHost(state, id)`:** اولی میگوید این عضو میزبان است یا نه. دومی میزبانی اتاق را به عضو دیگری میدهد. چون ترتیب نوار حضور به میزبان وابسته است، بعد از انتقال میزبانی ترتیب نمایش هم عوض میشود.
- **تابع `memberById(state, id)`:** عضو را با شناسهاش پیدا میکند و برای شناسهٔ ناشناخته `null` میدهد، نه خطا.
**چت** یک محدودیت نرخ ارسال دارد تا کسی اتاق را پر نکند:
```ts
export const RATE_LIMIT_MS = 1000;
export const MAX_BURST = 3;
```
> این ثابت فاصلهٔ مجاز میان دو پیام یک کاربر است. بدون آن، کاربر میتواند چت را پر کند؛ با آن، پیامی که زودتر از این فاصله فرستاده شود رد میشود.
>
> منطق محدودیت باید بر پایهٔ زمان آخرین پیام همان کاربر باشد، نه زمان کل اتاق. کاربران دیگر نباید تحت تأثیر قرار بگیرند.
- **تابع `canSend(messages, memberId, now)`:** میگوید این عضو در این لحظه اجازهٔ ارسال دارد یا نه و مقدار بولی برمیگرداند. قاعدهاش پنجرهٔ کشویی است: تعداد پیامهای **همان عضو** در بازهٔ `RATE_LIMIT_MS` گذشته باید **کمتر از** `MAX_BURST` باشد. یعنی با سقف سه پیام، عضو میتواند سه پیام پشتسرهم بفرستد ولی پیام چهارم داخل همان یک ثانیه رد میشود؛ بعد از گذشتن پنجره دوباره اجازه پیدا میکند. پیامهای بقیهٔ اعضا در این محاسبه اثری ندارند.
- **تابع `sendMessage(messages, members, memberId, text, now)`:** پیام تازه را به فهرست اضافه میکند و **فهرست تازه** برمیگرداند؛ فهرست ورودی دستنخورده میماند. سه فیلتر پیش از افزودن اعمال میشود: متن `trim` میشود، پیامی که بعد از `trim` خالی بماند اصلاً اضافه نمیشود و اگر محدودیت نرخ اجازه ندهد پیام رد میشود. در هر سه حالتِ رد، تابع خطا نمیدهد و فقط فهرست بدون تغییر برمیگردد.
- **تابع `parseMentions(text, members)`:** متن پیام را میگردد و شناسهٔ اعضایی را که با `@` نام برده شدهاند برمیگرداند. فقط نامهایی که به عضو واقعی اتاق میخورند به حساب میآیند؛ یک `@` با نام ناشناخته منشن نیست.
- **تابع `mentionsOf(messages, memberId)`:** از میان همهٔ پیامها، آنهایی را برمیگرداند که این عضو در آنها منشن شده است. نوار «منشنهای من» از همین تغذیه میشود.
- **توابع `messageCount(messages)` و `resetChatSeq()`:** اولی تعداد پیامها را برمیگرداند. دومی شمارندهٔ شناسهٔ پیامها را از نو شروع میکند تا شناسهها تکرارپذیر بمانند.
</details>
<details class="violet">
<summary>**پیادهسازی `engine/polls.ts`**</summary>
نظرسنجی زنده همان چیزی است که میزبان وسط بازی میپرسد و بقیه رأی میدهند. نکتهٔ طراحیاش این است که رأیها را **بر اساس عضو** نگه میدارید، نه بر اساس گزینه؛ یعنی برای هر عضو یک گزینه ذخیره میشود. همین انتخاب باعث میشود **«هر عضو فقط یک رأی»** خودبهخود برقرار بماند و عوض کردن رأی هم ساده شود. در قسمت زیر تعاریف و نحوه پیادهسازی هر کدام از توابع این ماژول بررسی میشوند:
|  |
| :-: |
| نظرسنجی زنده و درصد آرای هر گزینه |
- **تابع `createPoll(question, optionLabels, createdAt = 0)`:** نظرسنجی تازه میسازد. شناسهٔ گزینهها را خودش تولید میکند، به ترتیب `"o1"` و `"o2"` و همینطور جلو. برچسبهای خالی پیش از ساخت حذف میشوند. این تابع تنها جایی در کل سوال است که باید **خطا پرتاب کند**: اگر پرسش خالی باشد یا بعد از حذف برچسبهای خالی کمتر از دو گزینه بماند، نظرسنجی معنا ندارد و باید `throw` کند.
- **توابع `vote(poll, memberId, optionId)` و `unvote(poll, memberId)`:** اولی رأی یک عضو را ثبت میکند؛ چون هر عضو فقط یک رأی دارد، رأی دادن دوباره جایگزین رأی قبلی میشود نه اینکه رأی تازهای اضافه کند. دومی رأی عضو را پس میگیرد. هیچکدام روی نظرسنجی بسته اثر ندارند.
- **تابع `closePoll(poll)`:** نظرسنجی را میبندد. بعد از بسته شدن، هیچ رأی تازهای پذیرفته نمیشود ولی نتیجه همچنان قابل خواندن است.
- **توابع `totalVotes(poll)` و `votesFor(poll, optionId)` و `percentFor(poll, optionId)`:** اولی تعداد کل رأیهای ثبتشده، دومی تعداد رأیهای یک گزینه و سومی سهم آن گزینه را برمیگرداند. خروجی `percentFor` یک **عدد** درصدِ صحیح است، نه رشته. حالت مرزیاش تقسیم بر صفر است: وقتی هنوز هیچ رأیی ثبت نشده، برای همهٔ گزینهها `0` برمیگردد نه `NaN`.
- **تابع `leadingOptions(poll)`:** شناسهٔ گزینه یا گزینههای پیشتاز را برمیگرداند. عمداً آرایه است تا حالت تساوی هم پوشش داده شود؛ اگر چند گزینه بیشترین رأی را داشته باشند، **همهشان** برمیگردند و رابط کاربری هم باید همه را متمایز نشان بدهد.
- **توابع `hasVoted(poll, memberId)` و `votedOption(poll, memberId)`:** اولی میگوید این عضو رأی داده است یا نه و مقدار بولی برمیگرداند. دومی شناسهٔ گزینهای را که عضو انتخاب کرده برمیگرداند و اگر رأی نداده باشد `null`. رابط کاربری با همین دو، گزینهٔ انتخابشدهٔ کاربر را برجسته میکند.
- **تابع `resetPollSeq()`:** شمارندهٔ شناسهٔ نظرسنجیها را از نو شروع میکند تا شناسهها تکرارپذیر بمانند.
</details>
<details class="orange">
<summary>**پیادهسازی `engine/predictions.ts` و `engine/leaderboard.ts`**</summary>
**پیشبینی با نظرسنجی فرق دارد:** نظرسنجی نظر جمع را میگیرد، ولی پیشبینی **جواب درست** دارد و بعداً امتیاز میآورد. هر پرسش دو مرحله دارد؛ تا وقتی باز است اعضا میتوانند نظرشان را عوض کنند و بهمحض اینکه میزبان جواب را ثبت کند پرسش **قفل** میشود و دیگر تغییر نمیپذیرد. در قسمت زیر تعاریف و نحوه پیادهسازی هر کدام از توابع این ماژول بررسی میشوند:
|  |
| :-: |
| جدول امتیاز بر پایهٔ درستی پیشبینیها |
هر پرسش پیشبینی چهار گزینه دارد: `goal`، `card`، `var` و `none`.
```ts
export const POINTS_CORRECT = 10;
```
> این ثابتها نرخ امتیازدهی پیشبینیاند. پیشبینی درست امتیاز کامل میگیرد و حالتهای نزدیک امتیاز کمتر، تا جدول امتیاز فقط دو حالت درست و غلط نداشته باشد.
>
> این مقادیر را در کد پخش نکنید؛ همهجا از همین ثابتها بخوانید تا تغییرشان یکجا ممکن باشد.
- **تابع `makePrediction(predictions, memberId, questionId, choice)`:** پیشبینی یک عضو برای یک پرسش را ثبت میکند و ساختار تازه برمیگرداند. تا وقتی پرسش قفل نشده، ثبت دوباره انتخاب قبلی را جایگزین میکند؛ بعد از قفل شدن، هیچ تغییری پذیرفته نمیشود.
- **تابع `predictionFor(predictions, memberId, questionId)`:** انتخاب فعلی یک عضو برای یک پرسش را برمیگرداند و اگر چیزی ثبت نکرده باشد `null` میدهد. یک نکتهٔ ظریف هم که سیستم داوری میسنجد این است که گزینهٔ `none` یعنی «کاربر پیشبینی کرد که هیچکدام از اینها رخ نمیدهد» و یک انتخاب کاملاً معتبر است؛ این با `null` که یعنی «کاربر اصلاً شرکت نکرده» فرق دارد.
- **توابع `resolveQuestion(question, answer)` و `isLocked(question)`:** اولی جواب درست را روی پرسش ثبت میکند و همان لحظه قفلش میکند. دومی میگوید پرسش قفل شده است یا نه و مقدار بولی برمیگرداند؛ رابط کاربری با همین، گزینهها را غیرفعال میکند.
- **تابع `wasCorrect(predictions, question, memberId)`:** میگوید پیشبینی این عضو برای این پرسش درست بوده یا نه. برای پرسشی که هنوز جواب نگرفته یا عضوی که شرکت نکرده، نتیجه درست حساب نمیشود.
- **تابع `scoreMember(predictions, questions, memberId)`:** امتیاز کل پیشبینیهای یک عضو را روی همهٔ پرسشهای حلشده جمع میزند و برمیگرداند. نرخ امتیاز را از ثابتهای بالای همین فایل بخوانید و عدد را در کد پخش نکنید.
**جدول امتیاز** امتیاز پیشبینی و امتیاز واکنش را با هم جمع میزند:
```ts
export const REACTION_POINT = 1;
```
> این ثابت امتیاز هر واکنش است و در جمع امتیاز کاربر لحاظ میشود. سهمش عمداً کوچک است تا واکنشفرستادن جای پیشبینی درست را نگیرد.
>
> در محاسبهٔ جدول امتیاز، امتیاز پیشبینی و امتیاز واکنش با هم جمع میشوند.
- **تابع `totalScore(row)`:** امتیاز کل یک عضو را برمیگرداند، یعنی امتیاز پیشبینیها بهعلاوهٔ امتیاز واکنشهایش.
- **تابع `rank(rows)`:** فهرست را بر اساس امتیاز مرتب میکند و به هر ردیف یک `rank` میدهد. دو قاعده دارد که خروجی را قطعی میکنند و سیستم داوری روی هر دو حساب میکند:
+ تساوی امتیاز با **شناسهٔ عضو به ترتیب صعودی الفبایی** شکسته میشود، تا جدول با هر بار محاسبه یکسان دربیاید.
+ شمارهگذاری رتبهها به سبک **مسابقهای** است: دو نفر مساوی هر دو رتبهٔ `1` میگیرند و نفر بعدی رتبهٔ `3` میشود، نه `2`.
- **تابع `rankMap(ranked)`:** از فهرست رتبهبندیشده یک نگاشت از شناسهٔ عضو به رتبهاش میسازد، تا پیدا کردن رتبهٔ یک نفر بدون گشتن در کل فهرست ممکن باشد.
- **تابع `rankDelta(prevRanked, currRanked, memberId)`:** جابهجایی رتبهٔ یک عضو را نسبت به حالت قبلی برمیگرداند. علامتش مهم است: مقدار **مثبت یعنی صعود**، چون رتبهٔ کوچکتر بهتر است. نشانگر بالا و پایین کنار نام هر عضو از همین عدد میآید.
- **تابع `leader(ranked)`:** عضو صدرنشین جدول را برمیگرداند و برای فهرست خالی `null` میدهد.
</details>
<details class="teal">
<summary>**پیادهسازی `engine/reactions.ts` و `engine/timeline.ts`**</summary>
**واکنشها ایموجیهایی هستند** که روی صفحه بالا میروند و بعد از مدتی محو میشوند:
```ts
export const REACTION_LIFETIME_MS = 1600;
```
> این ثابت میگوید هر واکنش شناور چقدر روی صفحه بماند. بعد از این مدت باید از فهرست واکنشهای زنده حذف شود، وگرنه فهرست بینهایت بزرگ میشود.
>
> حذف باید بر اساس زمان ثبت هر واکنش انجام شود، نه با یک تایمر جداگانه برای هرکدام.
- **تابع `pushReaction(buffer, emoji, memberId, at, xSeed?)`:** واکنش تازهای به بافر اضافه میکند و بافر تازه برمیگرداند. هر واکنش زمان ثبتش را با خودش نگه میدارد؛ همین زمان است که بعداً تعیین میکند کِی باید محو شود. پارامتر اختیاری `xSeed` جای افقی ایموجی روی صفحه را مشخص میکند و عمداً پارامتر است تا انیمیشن **تکرارپذیر** بماند و به تصادف وابسته نباشد.
- **تابع `decay(buffer, now, lifetime)`:** واکنشهایی را که عمرشان تمام شده از بافر بیرون میاندازد و بافر تازه برمیگرداند. تصمیم حذف بر اساس زمان ثبت هر واکنش گرفته میشود، نه با تایمر جداگانه برای هرکدام. بدون این تابع، بافر با هر واکنش تازه بزرگتر میشود و هیچوقت کوچک نمیشود.
- **تابع `activeCount(buffer, now, lifetime)`:** تعداد واکنشهایی را که در این لحظه هنوز زندهاند برمیگرداند، بدون اینکه چیزی را از بافر حذف کند.
- **تابع `aggregate(buffer)`:** شمارش هر ایموجی را به شکل نگاشتی از ایموجی به تعداد برمیگرداند، مثلاً `{ "🔥": 3, "👏": 1 }`. نوار خلاصهٔ واکنشها از همین ساخته میشود.
- **تابع `totalReactions(buffer)`:** تعداد کل واکنشهای داخل بافر را برمیگرداند.
- **تابع `topEmoji(buffer)`:** پرتکرارترین ایموجی را برمیگرداند. دو حالت مرزی دارد: برای بافر خالی **رشتهٔ خالی** میدهد نه `null` و در تساوی، ایموجیای برنده است که **زودتر** در بافر دیده شده باشد؛ همین قاعده خروجی را قطعی میکند.
- **تابع `countsByMember(buffer)`:** نگاشتی از شناسهٔ عضو به تعداد واکنشهایش برمیگرداند. امتیاز واکنش در جدول امتیاز از همینجا میآید.
**خط زمان** رویدادهای بازی را یکییکی آزاد میکند:
```ts
export const initialTimeline: TimelineState = { cursor: 0, released: [], minute: 0 };
```
> این وضعیت اولیهٔ خط زمان است. مقدار `cursor` میگوید تا کجای رویدادها پیش رفتهایم و `released` رویدادهایی است که تا این لحظه منتشر شدهاند.
>
> خط زمان با هر تیک ساعت جلو میرود و رویدادهای رسیده را به `released` اضافه میکند. این شیء را تغییر ندهید و در هر گام وضعیت تازه بسازید.
خط زمان یک بازیِ از پیش نوشتهشده را شبیهسازی میکند: `script` همهٔ رویدادهای بازی را از قبل دارد، ولی کاربر باید آنها را یکییکی و بهمرور ببیند. وضعیت خط زمان میگوید تا کجای این فهرست پیش رفتهایم. همهٔ توابع زیر وضعیت ورودی را تغییر نمیدهند و وضعیت تازه برمیگردانند.
- **توابع `advance(state, script)` و `advanceBy(state, script, n)`:** اولی یک رویداد و دومی `n` رویداد جلو میرود و رویدادهای تازه را به فهرست منتشرشده اضافه میکند. وقتی به انتهای فهرست رسیدید، جلو رفتن بیشتر نباید خطا بدهد یا از انتها عبور کند؛ وضعیت همانجا میماند.
- **تابع `isComplete(state, script)`:** میگوید همهٔ رویدادهای بازی آزاد شدهاند یا نه و مقدار بولی برمیگرداند.
- **تابع `progress(state, script)`:** نسبت پیشرفت را بهصورت عددی بین `0` و `1` برمیگرداند تا نوار پیشرفت بازی از رویش ساخته شود. برای `script` خالی باید عدد معتبر بدهد، نه `NaN`.
- **تابع `score(state, home, away)`:** نتیجهٔ فعلی بازی را **از روی رویدادهای آزادشده** حساب میکند و شیئی به شکل `{ home, away }` برمیگرداند. یعنی نتیجه جایی ذخیره نمیشود و همیشه از خط زمان مشتق میشود؛ در شروع بازی هر دو صفرند و با هر رویداد گل، عدد سمت مربوطه بالا میرود.
- **تابع `eventsOfType(state, type)`:** از میان رویدادهای آزادشده فقط آنهایی را برمیگرداند که نوعشان با مقدار دادهشده میخواند؛ فیلترهای بالای خط زمان از همین استفاده میکنند.
</details>
<details class="purple">
<summary>**پیادهسازی `engine/command.ts` و `engine/virtual.ts` و `engine/persistence.ts`**</summary>
**پشتهٔ فرمان** برای بازگشت و بازانجام کارهای میزبان است:
```ts
export const emptyStack: CommandStack = { past: [], future: [] };
```
> این ساختار پشتهٔ بازگشت و بازانجام است. هر تغییر به `past` اضافه میشود و `future` هم چیزی است که با بازگشت کنار گذاشته شده و میشود دوباره اعمالش کرد.
>
> با انجام یک عمل تازه، `future` باید خالی شود؛ وگرنه بازانجامِ چیزی ممکن میشود که دیگر با وضعیت فعلی نمیخواند.
- **تابع `push(stack, command)`:** فرمان تازهای ثبت میکند و پشتهٔ تازه برمیگرداند. یک کار دوم هم انجام میدهد که فراموش کردنش خطای رایج این الگوست: شاخهٔ `future` را **پاک میکند**. اگر بعد از یک بازگشت، کار تازهای انجام شود، دیگر نباید بشود چیزی را که کنار گذاشته شده بازانجام کرد، چون با وضعیت فعلی نمیخواند.
- **توابع `canUndo(stack)` و `canRedo(stack)`:** میگویند فرمانی برای بازگشت یا بازانجام مانده است یا نه و مقدار بولی برمیگردانند. دکمههای میزبان با همین دو فعال و غیرفعال میشوند.
- **توابع `undo(stack)` و `redo(stack)`:** یک قدم عقب یا جلو میروند. نکتهٔ مهمشان شکل خروجی است: شیئی به شکل `{ stack, command }` برمیگردانند، یعنی هم پشتهٔ تازه و هم **خود فرمانی که باید اثرش برگردانده یا دوباره اعمال شود**. دلیلش این است که پشته فقط تاریخچه را نگه میدارد و اعمال واقعی جای دیگری انجام میشود. اگر کاری برای انجام نمانده باشد، `command` برابر `null` است و پشته بدون تغییر برمیگردد.
- **توابع `depth(stack)` و `lastLabel(stack)`:** اولی تعداد فرمانهای ثبتشده را برمیگرداند. دومی برچسب آخرین فرمان را میدهد تا کنار دکمهٔ بازگشت نوشته شود و برای پشتهٔ خالی **رشتهٔ خالی** برمیگرداند نه `null`.
**مجازیسازی فهرست (virtualization)** یعنی از یک فهرست طولانی چت فقط همان بخشی که در کادر دید کاربر است رندر شود، تا صفحه با هزاران پیام کند نشود:
- **تابع `computeRange({ itemCount, rowHeight, viewportHeight, scrollTop })`:** مغز مجازیسازی است. از روی موقعیت فعلی اسکرول و ابعاد، حساب میکند کدام بازه از ردیفها باید رندر شود و شیئی با **چهار** فیلد برمیگرداند:
+ `start` و `end` اندیس ابتدا و انتهای بازهٔ قابل نمایش. کمی `overscan` هم لحاظ میشود، یعنی چند ردیف بیشتر از کادر دید رندر میشود تا هنگام اسکرول سریع، جای خالی دیده نشود. مقدار `end` هیچوقت از `itemCount` بیرون نمیزند.
+ `offsetTop` فاصلهای که ردیفهای رندرشده باید از بالا داشته باشند تا سر جای درستشان بنشینند؛ برابر است با `start` ضربدر ارتفاع هر ردیف.
+ `totalHeight` ارتفاع کل فهرست اگر همهاش رندر میشد؛ همین است که نوار اسکرول را به اندازهٔ واقعی نگه میدارد در حالی که فقط چند ردیف در `DOM` هستند.
حالت مرزیاش مهم است: وقتی فهرست خالی است یا ارتفاع ردیف صفر است، هر دو `start` و `end` باید صفر باشند و تابع نباید تقسیم بر صفر انجام بدهد.
- **تابع `sliceWindow(items, range)`:** از آرایهٔ کامل، فقط همان بازهای را که `computeRange` مشخص کرده برمیدارد. همین آرایهٔ کوچک است که واقعاً رندر میشود.
- **تابع `bottomOffset(itemCount, rowHeight, viewportHeight)`:** مقدار اسکرول لازم برای رسیدن به انتهای فهرست را برمیگرداند. وقتی پیام تازهای میآید، چت با همین مقدار تا پایین اسکرول میشود. اگر کل فهرست کوتاهتر از کادر دید باشد، جایی برای اسکرول نیست و خروجی نباید منفی شود.
**ذخیرهسازی**:
```ts
export const STORAGE_KEY = "kickoff.room.v1";
```
> این کلید همان جایی است که وضعیت اتاق زیر آن در `localStorage` ذخیره میشود و پسوند نسخه در نامش عمدی است.
>
> هنگام بازیابی، اگر مقدار ذخیرهشده خراب یا ناسازگار بود، برنامه باید با وضعیت اولیه بالا بیاید و خطا ندهد.
- **تابع `saveRoom(data, storage = localStorage)`:** وضعیت اتاق را زیر کلید بالا ذخیره میکند. شکل `data` دقیقاً `{ polls, predictions, chat, reactions }` است و هر چهار مقدار باید آرایه باشند.
- **تابع `loadRoom(storage = localStorage)`:** وضعیت ذخیرهشده را میخواند و برمیگرداند. مقاومتش در برابر دادهٔ خراب همان چیزی است که سنجیده میشود؛ در هر سه حالت زیر باید `null` برگردد و **هیچ خطایی پرتاب نشود**:
+ وقتی هنوز چیزی ذخیره نشده باشد.
+ وقتی مقدار ذخیرهشده `JSON` معتبری نباشد.
+ وقتی هر کدام از آن چهار آرایه در دادهٔ ذخیرهشده نباشد یا آرایه نباشد.
پارامتر `storage` مثل بقیهٔ جاها تزریق میشود تا بشود این دو تابع را با یک حافظهٔ ساختگی و بدون مرورگر هم سنجید.
</details>
<details class="yellow">
<summary>**پیادهسازی `hooks/useMatchClock.ts` و `RoomContext`**</summary>
- **هوک `useMatchClock(onTick, options)`:** ضربان برنامه است: هر چند ثانیه یکبار تابع `onTick` را صدا میزند تا خط زمان بازی یک قدم جلو برود. پارامتر `options` سه چیز را قابل تنظیم میکند: `intervalMs` فاصلهٔ میان تیکها با پیشفرض `1800`، `autoStart` که میگوید ساعت از همان ابتدا راه بیفتد یا نه با پیشفرض `true` و توابع `setInterval` و `clearInterval` که **تزریقشدنی**اند.
تزریقپذیر بودن تایمر عمدی است و همان کاری را میکند که تزریق `storage` در ماژول ذخیرهسازی: اجازه میدهد در سیسم داوری یک تایمر ساختگی داده شود و زمان دستی جلو برود، بدون اینکه واقعاً منتظر بمانیم. حواستان به پاکسازی هم باشد؛ وقتی کامپوننت برداشته میشود، تایمر باید متوقف شود وگرنه بعد از خروج از صفحه هم تیک میزند.
|  |
| :-: |
| همان صفحه در تم تیره |
- **کامپوننت `RoomProvider` و هوک `useRoom()`:** `RoomProvider` کل وضعیت برنامه را یکجا نگه میدارد: اتاق، چت، نظرسنجیها، پیشبینیها، واکنشها، خط زمان، پشتهٔ فرمان و تم. هوک `useRoom()` راه دسترسی کامپوننتها به این وضعیت است و اگر بیرون از `RoomProvider` صدا زده شود باید **خطا پرتاب کند**؛ اینطور بهجای یک `undefined` مرموز در عمق کامپوننت، خطای روشنی میگیرید.
این کامپوننت یک پراپ اختیاری `now?: () => number` میگیرد که پیشفرضش `Date.now` است. هر جا به زمان فعلی نیاز دارید (مهر زمانی پیامها و واکنشها) باید از همین تابع بخوانید تا سیستم داوری بتواند زمان را کنترل کنند.
همچنین `RoomProvider` باید دو پرسش پیشبینی اولیه بسازد: یکی با شناسهٔ `q1` و یکی با شناسهٔ `q2`. هر دو چهار گزینهٔ `goal` و `card` و `var` و `none` دارند و در شروع باز (قفلنشده) هستند.
+ **توجه: نقش کاربر.** کاربر فعلی (`ME_ID`) میزبان **نیست**. هر کنترلی که کار میزبان است باید برای مهمان اصلاً **رندر نشود**، نه اینکه `disabled` شود. اینها شامل `undo-button` و فرم `poll-form` و دکمههای `resolve-<questionId>-<choice>` میشوند.
**تم** بهصورت ویژگی `data-theme` روی `document.documentElement` نوشته میشود؛ مقدارش `dark` یا `light` است، **در شروع `light`**. انتخاب کاربر در `localStorage` ذخیره میشود.
</details>
<details class="olive">
<summary>**کامپوننتها و صفحهها**</summary>
هر کامپوننت باید `default export` داشته باشد.
**پوستهٔ مشترک:**
+ لینکهای منو: `nav-polls`، `nav-predictions`، `nav-leaderboard`، `nav-timeline` و `nav-settings`.
+ نتیجهٔ زنده با `data-testid="live-score"` که با فرمت `formatScore` نوشته میشود.
+ تعداد آنلاینها با `data-testid="online-count"`.
+ امتیاز خود کاربر با `data-testid="my-score"`.
+ دکمهٔ `theme-toggle` و شمارندهٔ `command-depth` که متنش به شکل `12 actions` است.
+ دکمهٔ `undo-button` که فقط برای میزبان رندر میشود؛ چون کاربر فعلی مهمان است، در صفحه نباید باشد.
+ عنصر `settings-role` که باید کلمهٔ `host` یا `guest` را داشته باشد.
+ ناحیهٔ اعلان با `data-testid="toast"`. وقتی پیامی بهخاطر محدودیت نرخ رد میشود، یک اعلان نشان داده میشود و **خودش بعد از حداکثر سه ثانیه** پاک میشود. وقتی اعلانی در کار نیست، این عنصر اصلاً رندر نمیشود.
|  |
| :-: |
| فرم ثبت پیشبینی پیش از شروع بازی |
**صفحهٔ اتاق (`room-page`):**
+ نوار حضور با `presence-rail` که داخلش `presence-list` است و برای هر عضو یک `presence-<id>` که ویژگی `data-online` با مقدار `"true"` یا `"false"` دارد. عضو میزبان یک `host-badge-<id>` میگیرد.
+ پنل چت با `chat-panel` که شامل `chat-list` (با `aria-live="polite"`)، فرم `chat-form`، ورودی `chat-input` و دکمهٔ `chat-send` است. تا وقتی پیامی نیست، `chat-empty` نشان داده میشود. منشنها داخل متن با `data-testid="mention"` مشخص میشوند و متن هر پیام باید داخل عنصر جدای خودش باشد، جدا از نام فرستنده.
+ نوار واکنش با `reaction-bar` که برای هر ایموجی یک دکمهٔ `react-<emoji>` با `aria-label="Send <emoji>"` دارد، بههمراه `reaction-total` که `aria-live="polite"` دارد و متنش به شکل `0 reactions in the air` است.
+ لایهٔ ایموجیهای شناور با `emoji-storm` و `storm-layer` که `aria-hidden="true"` میگیرد.
+ نوار خط زمان با `timeline-ribbon` که داخلش یک عنصر با `role="progressbar"` دارد. تا وقتی هیچ رویدادی آزاد نشده، همینجا `timeline-empty` نشان داده میشود.
**صفحهٔ نظرسنجی (`polls-page`):** فرم `poll-form` (فقط برای میزبان؛ برای مهمان اصلاً رندر نمیشود)، فهرست `poll-list` و برای هر نظرسنجی یک کارت با گزینههای `choice-<pollId>-<optionId>`. تا وقتی نظرسنجیای نیست، `poll-list` هم رندر نمیشود و فقط `polls-empty` دیده میشود.
**صفحهٔ پیشبینی (`predictions-page`):** پنل `prediction-panel` که برای هر پرسش یک `question-<id>` دارد و داخل آن بخش گزینهها `choices-<id>` با دکمههای `choice-<questionId>-<choice>` که هرکدام `aria-pressed` میگیرند. پرسش قفلشده `question-locked-<id>` میگیرد. دکمههای `resolve-<questionId>-<choice>` کار میزباناند و برای مهمان اصلاً رندر نمیشوند.
|  |
| :-: |
| کنترلهای اضافی که فقط میزبان اتاق میبیند |
**صفحهٔ جدول (`leaderboard-page`):** فهرست `leaderboard-list` که برای هر عضو یک `leader-row-<id>` دارد، بههمراه `leader-rank-<id>` و `leader-total-<id>`.
**صفحهٔ خط زمان (`timeline-page`):** برای هر رویداد آزادشده یک `timeline-event-<id>`؛ تا وقتی چیزی آزاد نشده `timeline-empty`.
**صفحهٔ تنظیمات (`settings-page`):** بخشهای `settings-role`، `settings-persistence` و `settings-log`.
</details>
# **آنچه سیستم داوری بررسی میکند**
**سیستم داوری** توابع `engine` را مستقیم `import` میکنند و برنامه را داخل `MemoryRouter` و `RoomProvider` اجرا میکند. رنگها، فونتها و اندازهٔ پیکسلی در سیستم داوری بررسی نمیشوند. آنچه در سیستم بررسی میشود:
+ **مقدار بازگشتی توابع** `format` و `polls` و `chat` و `timeline` و `command`، دقیقاً مطابق مثالهای بالا
+ **محدودیت نرخ ارسال چت با زمان تزریقشده**
+ **برگرداندن** همهٔ گزینههای پیشتاز در `leadingOptions` هنگام تساوی
+ **پایدار بودن** ترتیب در `rank` هنگام تساوی امتیاز
+ **خالی شدن** `future` هنگام `push` در پشتهٔ فرمان و برگشت `command: null` وقتی کاری نیست
+ **برگشت** `null` در `loadRoom` برای مقدار خراب
+ **پرتاب شدن** خطا وقتی `useRoom()` بیرون از `RoomProvider` صدا زده شود
+ **وجود** `default export` برای هر کامپوننت
+ **وجود** عناصر با `data-testid`های گفتهشده و ترتیب اعضا در نوار حضور
+ مقدار `data-theme` روی `document.documentElement`
+ **ذخیره و بازیابی وضعیت** زیر کلید `kickoff.room.v1`
آنچه در سیستم داوری بررسی **نمیشود** و در پیادهسازی آن میتوانید به دلخواه عمل کنید:
+ **مسیر حرکت** و **انیمیشن** ایموجیهای شناور
+ **چیدمان** و **طراحی** بصری صفحهها
# **آنچه باید آپلود کنید**
- **توجه:** فایل زیپ شما باید همان ساختار پروژهٔ اولیه را داشته باشد و پوشهٔ `node_modules` را **نداشته باشد.**
```plaintext
└── src/
├── components/
│ ├── ChatPanel.tsx
│ ├── EmojiStorm.tsx
│ ├── Leaderboard.tsx
│ ├── PollCard.tsx
│ ├── PollPanel.tsx
│ ├── PredictionPanel.tsx
│ ├── PresenceRail.tsx
│ ├── ThemeToggle.tsx
│ ├── TimelineRibbon.tsx
│ └── Toast.tsx
├── context/
│ └── RoomContext.tsx
├── engine/
│ ├── chat.ts
│ ├── command.ts
│ ├── format.ts
│ ├── leaderboard.ts
│ ├── persistence.ts
│ ├── polls.ts
│ ├── predictions.ts
│ ├── reactions.ts
│ ├── room.ts
│ ├── timeline.ts
│ └── virtual.ts
├── hooks/
│ └── useMatchClock.ts
├── pages/
│ ├── LeaderboardPage.tsx
│ ├── PollsPage.tsx
│ ├── PredictionsPage.tsx
│ ├── RoomPage.tsx
│ ├── SettingsPage.tsx
│ └── TimelinePage.tsx
└── App.tsx
```
- **توجه:** درخت بالا دقیقاً همان چیزی است که باید در فایل زیپ آپلود کنید. پوشهٔ `src/` باید در ریشهٔ فایل زیپ باشد و ساختار داخلش همان ساختار پروژهٔ اولیه بماند.
- **توجه:** پوشهٔ `node_modules` را در فایل زیپ ارسالی نگذارید و فایل تازهای اضافه نکنید.
- **توجه:** فایلها باید در **ریشهٔ** فایل زیپ باشند، نه داخل یک پوشهٔ اضافه. اگر همهچیز را داخل پوشهای به اسم `answer` بگذارید، مسیرهای موردانتظار پیدا نمیشوند و داوری اجرا نمیشود.
- **توجه: فایل جدیدی اضافه نکنید.** سیستم داوری کوئرا فقط فایلهای بالا را برمیدارد، پس اگر ماژول جدیدی بسازید و از جایی `import`ش کنید، بیلد پروژه هنگام داوری شکست میخورد و سیستم داوری نمره صفر را لحاظ میکند. کد کمکی را داخل همان فایلهای موجود بنویسید.
- **توجه:** تمام متنهای داخل صفحه **انگلیسی** هستند.
- **توجه:** ارزیابی به رنگ و فونت و فاصله کاری ندارد؛ مقدار بازگشتی توابع، رفتار کامپوننتها و `data-testid`ها بررسی میشوند.
- **توجه:** هیچجا از `Math.random()` استفاده نکنید تا نتیجهها تکرارپذیر بمانند.