شکل فعلی، لایه‌ی ترسیمات، و تنظیمات canvas

پس از یادگیری شکل‌های پایه، شما می‌توانید تقریبا هر نوع شکل پیچیده‌ای در canvas رسم کنید. با این حال درست است که چیز‌های زیادی آموخته‌اید، اما انجام برخی موارد ساده و ابتدایی هنوز برای شما غیرممکن است! مواردی هستند که یا وجود آن‌ها لازم است، یا می‌تواند در موارد بسیاری به ما کمک کند و در این آموزش به بررسی آن‌ها می‌پردازیم.

در این آموزش به بررسی سه ساختار مهم در canvas و روش‌های ایجاد تغییر در هرکدام یا استفاده از آن‌ها می‌پردازیم. شاید در این بخش با کد زیادی مواجه نشوید، اما مفاهیم گفته‌شده به همان اندازه مهم هستند و یادگیری آن‌ها به درک بهتر روش کار و رفتار canvas کمک می‌کند.

شکل فعلی

پیش از این چندین بار به حافظه‌ی canvas یا «شکل فعلی» اشاره کردیم. شکل فعلی حافظه‌ای است که ترسیمات ابتدا در آن قرار می‌گیرند (به جز مواردی که گفته شد) و سپس تصمیم بر رسم آن‌ها گرفته می‌شود. پیش از توضیح بیشتر درباره‌ی این ساختار، بگویید چطور می‌توان دو دایره با دو رنگ متفاوت (مثلا سبز و قرمز) در canvas رسم کرد؟ کد زیر سعی می‌کند این کار را انجام دهد. آن را اجرا کرده و نتیجه را ببینید.


cvs.width = 600;
cvs.height = 300;

/* RED CIRCLE */
ctx.arc(200, 150, 100, 0, Math.PI * 2);
ctx.fillStyle = "#FF4500"; /* RED */
ctx.fill();

/* GREEN CIRCLE */
ctx.arc(400, 150, 100, 0, Math.PI * 2);
ctx.fillStyle = "#32CD32"; /* GREEN */
ctx.fill();

چرا نتیجه آنطور که انتظار داریم نیست؟ چرا باید دو دایره‌ی سبز رسم شده باشند؟ پس دایره‌ی قرمز کجاست؟ پاسخ در شکل فعلی‌ـست! گفتیم شکل‌ها پیش از اینکه دستور رسم صادر شود ابتدا وارد شکل فعلی می‌شوند، اما نگفتیم بعد از رسم شدن از شکل فعلی پاک می‌شوند! ترسیماتی که تعریف کرده‌ایم حتی بعد از رسم شدن نیز درون شکل فعلی باقی می‌مانند.

علت اینکه نتیجه‌ی کد بالا دو دایره‌ی سبز است نیز همین است. در کد بالا ابتدا دایره‌ی اول را تعریف کردیم (وارد شکل فعلی شد) و شکل فعلی را به رنگ قرمز رسم کردیم. سپس دایره‌ی دوم را تعریف کردیم (که طبعا وارد شکل فعلی شد) و سپس شکل فعلی را به رنگ سبز رسم کردیم. خب در رسم دوم چه چیزی درون شکل فعلی وجود دارد؟ درست حدس زدید! هر دو دایره در رسم دوم درون شکل فعلی هستند و وقتی دستور رسم صادر می‌شود، همان دایره به رنگ سبز روی دایره‌ی قرمز قبلی رسم می‌شود و این نتیجه را رقم می‌زند.

متد beginPath

این متد راه‌حل مشکل ماست. این متد شکل فعلی را پاک می‌کند و هیچ ورودی یا خروجی‌ای ندارد. موقعیت‌های فراوانی پیش می‌آید که در آن‌ها لازم است شکل فعلی پاک شود و شکل جدیدی جایگزین آن شود و این متد انجام این کار را بر عهده دارد. بر خلاف ظاهرش، این متد هیچ ارتباطی با متد closePath ندارد! کد بالا را با استفاده از این متد به صورت زیر اصلاح می‌کنیم:


cvs.width = 600;
cvs.height = 300;

/* RED CIRCLE */
ctx.arc(200, 150, 100, 0, Math.PI * 2);
ctx.fillStyle = "#FF4500"; /* RED */
ctx.fill();

/* CLEAR CURRENT PATH */
ctx.beginPath();

/* GREEN CIRCLE */
ctx.arc(400, 150, 100, 0, Math.PI * 2);
ctx.fillStyle = "#32CD32"; /* GREEN */
ctx.fill();

در این کد، بعد از اینکه دایره‌ی اول رسم شد، شکل فعلی پاک می‌شود و دایره‌ی دوم درون آن قرار می‌گیرد. توجه کنید هرگاه این متد فراخوانده می‌شود، نه‌تنها شکل فعلی پاک می‌شود، بلکه مختصات قلم نیز به حالت اولیه یعنی «تعریف نشده» بازمی‌گردد. نمونه کد زیر به خوبی این مطلب را بیان می‌کند:


ctx.lineWidth = 5;

ctx.moveTo(20, 20);
ctx.lineTo(80, 80);

ctx.strokeStyle = "#F00";
ctx.stroke();

ctx.beginPath();
ctx.lineTo(140, 20);

ctx.strokeStyle = "#00F";
ctx.stroke();

در کد بالا انتظار می‌رود که یک خط قرمز از مختصات (20,20) به (80,80) رسم شود، سپس یک خط آبی از (80,80) به (140,20) رسم شود؛ اما نتیجه فقط یک خط قرمز است. علت این است که متد beginPath که پیش از lineTo دوم  اجرا شد، مختصات قلم را به حالت اولیه، یعنی «تعریف نشده»، بازگرداند و خط دوم رسم نشد.

متد isPointInPath

این متد دو ورودی می‌گیرد که مختصات یک نقطه هستند. این متد پس از دریافت نقطه، بررسی می‌کند که آیا این نقطه درون شکل فعلی قرار دارد یا نه. سپس بر اساس آن مقدار true یا false برمی‌گرداند. true یعنی نقطه درون شکل فعلی قرار می‌گیرد، و false یعنی درون آن قرار نمی‌گیرد. بررسی یک مثال به درک بهتر موضوع کمک می‌کند؛ پس به کد زیر دقت کنید:


/* SET A PATH */
ctx.rect(50, 50, 100, 100);
ctx.arc(100, 100, 50, 0, Math.PI * 2);

ctx.moveTo(100, 100);
ctx.lineTo(350, 100);

ctx.lineTo(350, 350);
ctx.closePath();

/* FILL THE PATH */
ctx.fillStyle = "#C00"; /* RED */
ctx.fill();

/* CHECK IF IS IN THE PATH */
function check_cursor (e) {
    let box = cvs.getBoundingClientRect(),
    
        x = e.clientX - box.left,
        y = e.clientY - box.top;
    
    if (ctx.isPointInPath(x, y)) ctx.fillStyle = "#0C0"; /* GREEN */
    else ctx.fillStyle = "#C00"; /* RED */
    
    ctx.fill();
}

cvs.addEventListener("mousemove", check_cursor);

در کد بالا که به ظاهر پیچیده است، ابتدا یک شکل شامل یک مربع، دایره، و یک مثلث تعریف شده و سپس رسم می‌شود. حال تابع check_cursor تعریف می‌شود. این تابع مختصات موس روی canvas را پیدا کرده و با استفاده از متد isPointInPath بررسی می‌کند که آیا این مختصات درون شکل فعلی است یا نه. اگر موس درون شکل فعلی قرار داشت، ویژگی fillStyle را به رنگ سبز تعیین می‌کند و در غیر این صورت آن را قرمز می‌کند، سپس شکل را با استفاده از متد fill رنگ می‌کند.

کد بالا را اجرا کرده و نتیجه را ببینید. اگر موس را به داخل شکل ببرید رنگ آن سبز می‌شود، و اگر موس را از آن خارج کنید، رنگ آن به قرمز برمی‌گردد. اگر بخواهیم دقیق‌تر آن‌را توصیف کنیم، با هر حرکت موس در canvas، مختصات جدید موس بررسی شده و بر اساس آن ویژگی fillStyle تعیین می‌شود، سپس شکل فعلی، به آن رنگ رسم می‌شود. در واقع شکل تغییر رنگ نمی‌دهد، بلکه با رنگ جدید، به روی شکل قبلی رسم می‌شود.

متد isPointInStroke

این متد نیز شبیه به متد قبل رفتار می‌کند، با این تفاوت که وجود نقطه در حاشیه‌ی شکل را بررسی می‌کند، نه تمام شکل را. برای این متد نیازی به بررسی کد نیست اما می‌توانید در کد پیشین، به جای متد قبلی این متد را قرار داده و به جای fillStyle و fill از strokeStyle و stroke استفاده کنید.

ویژگی جالب این متد آن است که ویژگی‌های خط به‌ویژه lineWidth روی رفتار این متد اثرگذار هستند! اگر کد بالا را برای آزمایش تغییر دهید و مثلا ویژگی lineWidth را نیز به آن اضافه کنید، متوجه خواهید شد که با تغییر اندازه‌ی حاشیه، رفتار این متد نیز تغییر می‌کند. این ویژگی می‌تواند در موارد فراوانی کاربردی باشد. علاوه بر ویژگی‌های خط، خط‌چین تعیین‌شده برای شکل (که در آموزش‌های آینده به آن خواهیم پرداخت) نیز روی این متد اثر می‌گذارد. در بخش مربوط به خط‌چین یک نمونه مربوط به این متد بررسی خواهد شد.

چرا به شکل فعلی نیاز داریم؟

اینکه چرا به شکل فعلی نیاز داریم تا حدودی بدیهی است! از آنجایی که بیشتر ترسیمات canvas توسط متد‌های مربوط به خط انجام می‌شوند، وجود یک ساختار برای ذخیره‌ی این خطوط و ساخت یک شکل بر اساس آن‌ها لازم است.

حال شاید از خود بپرسید «آیا می‌توان چندین شکل فعلی داشت و از هرکدام به دلخواه استفاده کرد؟» پاسخ این است که زمینه به تنهایی نمی‌تواند چنین کاری انجام دهد. برای داشتن چند شکل فعلی راه‌حلی هست که کلاس Path2D نام دارد. این کلاس که سال‌ها بعد به canvas اضافه شد، قدرت فراوانی به آن می‌دهد و ما نیز مفصل به آن خواهیم پرداخت.

شاید بگویید نیازی به وجود چند شکل فعلی نیست و یک شکل فعلی برای کار کافیست؛ و درست نیز فکر می‌کنید! وجود یک شکل فعلی در canvas ضروری است، اما وجود چند شکل فعلی ضروری نیست، بلکه در موارد نه‌چندان کمی می‌تواند بسیار کاربردی باشد. به عنوان یک نمونه، در یک بازی که در آن برخورد میان بازیکن با عناصر درون صفحه بررسی می‌شود، استفاده از فقط یک شکل فعلی باعث افت شدید سرعت برنامه می‌شود. در چنین موردی چند شکل فعلی حکم فرشته‌ی نجات را دارد!

یک برداشت نادرست که در canvas وجود دارد، این است که برخی فکر می‌کنند شکل فعلی به اندازه و ابعاد canvas ارتباط دارد، حال آنکه این تصور کاملا اشتباه است. شکل فعلی فقط وقتی به ابعاد canvas مربوط می‌شود که قرار باشد روی آن رسم شود. هنگام رسم، آن بخش‌هایی که در محدوده‌ی لایه‌ی ترسیمات قرار می‌گیرند رسم می‌شوند. شما حتی می‌توانید یک عنصر canvas با ابعاد صفر در صفر بسازید و بدون مشکل از شکل فعلی آن و ویژگی‌هایی که دارد استفاده کنید.

لایه‌ی ترسیمات

این ساختار تعریف ساده‌ای دارد. هرچیزی که در عنصر canvas رسم می‌شود، وارد لایه‌ی ترسیمات شده است. لایه‌ی ترسیمات هرچیزی است که درون عنصر canvas رسم شده است و دیده می‌شود. این یعنی این لایه (برخلاف شکل فعلی) محدود به ابعاد عنصر canvas است و هرگز فراتر از آن نمی‌رود. متدها و ویژگی‌هایی برای تغییر تعامل این لایه با شکل فعلی یا دیگر ترسیماتی که وارد آن می‌شوند وجود دارد اما در این بخش فقط به دو متد مهم می‌پردازیم و دیگر موارد را در بخش مربوط به خود بررسی می‌کنیم.

متد clearRect

بالاخره روش پاک کردن چیزی که رسم کرده‌اید را می‌آموزید! این متد شبیه به متد fillRect رفتار می‌کند و همان ورودی‌ها را دارد؛ با این تفاوت که به جای رسم چیزی، مساحتی که از ورودی‌هایش دریافت کرده را پاک می‌کند. کد زیر را درنظر بگیرید:


ctx.arc(200, 200, 150, 0, Math.PI * 2);

ctx.fillStyle = "#00C";
ctx.fill(); /* DRAW CIRCLE */

ctx.clearRect(100, 100, 150, 150);

در کد بالا ابتدا یک دایره رسم می‌شود، سپس با استفاده از متد clearRect مساحتی به اندازه‌ی یک مربع به طول 150 در مختصات (100,100) از آن پاک می‌شود. این متد در مواردی که لازم است ترسیمات قبلی پاک شوند و ترسیمات جدیدی به جای آن‌ها رسم شوند، کاربرد دارد. برای نمونه در انیمیشن‌ها این متد کاربرد زیادی دارد. البته مواردی نیز هستند که نیازی به استفاده از این متد نیست.

همانطور که متوجه شده‌اید، این متد فقط مساحتی به شکل مستطیل را پاک می‌کند، اما موارد زیادی وجود دارند که در آن‌ها لازم است مساحتی به شکل دایره یا مثلث یا هر شکل دیگری پاک شود. نگران نباشید برای آن نیز راهی هست اما در آموزش‌های بعدی به آن خواهیم پرداخت.

متد clip

این متد لایه‌ی ترسیمات را به شکل فعلی محدود می‌کند. بعد از اجرای این متد، تمام ترسیمات خارج از آن محدوده رسم نمی‌شوند. برای درک بهتر این موضوع کد زیر را درنظر بگیرید:


/* DRAW A TRIANGLE */
ctx.moveTo(250, 50);
ctx.lineTo(50, 400);

ctx.lineTo(450, 400);
ctx.closePath();

/* DRAW BORDER */
ctx.stroke();

/* CLIP THE DRAWING AREA */
ctx.clip();
ctx.beginPath();

ctx.arc(250, 300, 150, 0, Math.PI * 2);

ctx.fillStyle = "#DD0";
ctx.fill();

در کد بالا ابتدا یک مثلث تعریف می‌شود، سپس برای نمایش بهتر به آن حاشیه داده می‌شود، سپس با استفاده از متد clip، لایه‌ی ترسیمات به این مثلث محدود می‌شود. سپس شکل فعلی پاک شده و یک دایره‌ی زرد رسم می‌شود؛ اما همانطور که می‌بینید بخش‌هایی از دایره که خارج از محدوده‌ی لایه‌ی ترسیمات بودند رسم نشدند.

برای محدود کردن لایه‌ی ترسیمات لازم است یک شکل فعلی دارای مساحت تعریف شود؛ منظور از دارای مساحت این است که اگر متد fill برای آن شکل اجرا شد، چیزی در canvas رسم شود. البته لازم نیست شکل فعلی حتما رسم شده و سپس متد clip فراخوانی شود، فقط کافیست شکل فعلی مناسب تعریف شده باشد. اگر شکل فعلی مناسب تعریف نشود، دیگر چیزی در canvas رسم نمی‌شود!

مشکل این متد این است که دیگر نمی‌توان محدوده را به حالت اولیه بازگرداند. البته اگر از دو متد save و restore همراه با آن استفاده کنیم، می‌توان این مشکل را پشت سر گذاشت. این دو متد قدرت و کاربرد فراوانی دارند و در آموزش ویژه‌ی خودشان به آن‌ها خواهیم پرداخت.

تنظیمات محیط و ترسیمات

در متد‌هایی که تاکنون بررسی کرده‌ایم، تنظیماتی اختیاری وجود دارند که می‌توانند نوع ترسیمات و موارد دیگر را تعیین کنند. علت اینکه در همان آموزش‌های پیشین به آن‌ها نپرداختیم، این بود که از پیچیدگی مطالب دوری کنیم تا خواننده درک مناسبی از موضوع به دست بیاورد.

متد getContext

این متد برخلاف چیزی که در آموزش‌های پیشین گفته شد، دو ورودی می‌پذیرد که ورودی دوم اختیاری است. ورودی اول از نوع متن بوده و نوع زمینه را تعیین می‌کند. ورودی دوم از نوع شئ است و دو ویژگی زیر را برای زمینه تعیین می‌کند. به جز این دو ویژگی، ویژگی‌های دیگری نیز هستند اما استاندارد نیستند و توسط همه‌ی مرورگر‌ها پشتیبانی نمی‌شوند.

ویژگی alpha

این ویژگی تعیین می‌کند که آیا ترسیمات دارای شفافیت (transparency) هستند یا خیر. مقدار پیش‌فرض آن true است. در مواردی که ترسیمات برنامه دارای شفافیت نیستند، قرار دادن این ویژگی به false باعث بهینه‌تر شدن ترسیمات توسط مرورگر و درنتیجه سرعت بیشتر می‌شود.

ویژگی desynchronized

این ویژگی با ناهمزمان‌سازی (desynchronizing) ترسیمات در مواردی به بهینه‌سازی و سرعت بیشتر کمک می‌کند.

به نمونه کد زیر توجه کنید. در کد زیر یک زمینه بدون شفافیت ایجاد می‌شود. به خاطر داشته باشید اگر ترسیمات فاقد شفافیت باشند، پس‌زمینه‌ی canvas نیز به رنگ سیاه درمی‌آید:


let ctx = cvs.getContext("2d", { alpha: false });

برای دسترسی به این شئ نیز متد getContextAttributes وجود دارد اما پشتیبانی مرورگر‌ها از آن بسیار ضعیف است و استفاده از آن را پیشنهاد نمی‌کنیم.

متد fill

این متد نیز یک ورودی اختیاری می‌پذیرد که نوع رسم را مشخص می‌کند. این ورودی از نوع متن بوده و یکی از دو مقدار “nonzero” و “evenodd” است. مقدار “nonzero” پیش‌فرض است. مقدار “nonzero” ترسیمات را با الگوی non-zero و مقدار “evenodd” ترسیمات را با الگوی even-odd انجام می‌دهد.

توجه کنید که این دو الگو در ترسیمات ساده به یک شکل رفتار می‌کنند و تفاوت اصلی آن‌ها در ترسیمات پیچیده‌ای است که اضلاع تداخل دارند. برای نمونه به کد زیر توجه کنید. در کد زیر یک شکل نسبتا پیچیده رسم می‌شود و سپس متد fill با مقدار “evenodd” اجرا می‌شود. مقدار را به “nonzero” عوض کرده و تغییرات را مشاهده کنید:


ctx.moveTo(100, 400);

ctx.lineTo(100, 100);
ctx.lineTo(400, 100);

ctx.lineTo(400, 400);
ctx.lineTo(200, 200);

ctx.lineTo(300, 200);
ctx.lineTo(100, 400);

ctx.lineTo(150, 150);
ctx.lineTo(350, 150);

ctx.lineTo(400, 400);
ctx.lineTo(100, 400);

ctx.fillStyle = "#CC0";
ctx.fill("evenodd");

این ویژگی می‌تواند در مواردی مانند ترسیمات متقارن بسیار کاربردی باشد، به گونه‌ای که می‌توان با استفاده از آن ترسیمات پیچیده‌ای را با کمترین مقدار کد ساخت. در بخش مربوطه بیشتر به این ویژگی و کاربرد آن خواهیم پرداخت.

متد isPointInPath

این متد نیز یک ورودی سوم دارد که دقیقا همان ورودی متد fill است. ممکن است شما شکل خود را با الگوی “evenodd” رسم کرده باشید و بخواهید متد isPointInPath نیز از این الگو پیروی کند. ورودی سوم در این موارد به شما کمک می‌کند. نمونه کد زیر مشابه کد اول برای متد isPointInPath است؛ با این تفاوت که  از الگوی “evenodd” برای ترسیمات استفاده شده است:


ctx.moveTo(100, 400);

ctx.lineTo(100, 100);
ctx.lineTo(400, 100);

ctx.lineTo(400, 400);
ctx.lineTo(200, 200);

ctx.lineTo(300, 200);
ctx.lineTo(100, 400);

ctx.lineTo(150, 150);
ctx.lineTo(350, 150);

ctx.lineTo(400, 400);
ctx.lineTo(100, 400);

ctx.fillStyle = "#CC0";
ctx.fill("evenodd");

function check_cursor (e) {
    let box = cvs.getBoundingClientRect(),
    
        x = e.clientX - box.left,
        y = e.clientY - box.top;
    
    if (ctx.isPointInPath(x, y, "evenodd")) ctx.fillStyle = "#0C0"; /* GREEN */
    else ctx.fillStyle = "#CC0"; /* YELLOW */
    
    ctx.fill("evenodd");
}

cvs.addEventListener("mousemove", check_cursor);

نتیجه‌گیری

در این بخش سعی کردیم درک مناسبی از ساختار‌های canvas و ویژگی‌های هرکدام به دست بیاوریم. تنها مورد باقی‌مانده در این رابطه، تعامل شکل فعلی و لایه‌ی ترسیمات است که در آموزش ویژه‌ی خود به آن خواهیم پرداخت. در آموزش بعدی به رسم تصویر می‌پردازیم. خواهشمندیم این آموزش را به‌خوبی فرا گرفته و تمرین کنید تا آموزش‌های آینده باعث سردرگمی شما نشوند.

حسین رفیعی

حسین رفیعی

طراحی و برنامه‌نویسی رو از وب شروع کردم و مثل خیلی از شماها آموزش‌های آقای سیدی خیلی بهم کمک کرد. هرچند این روزا تمرکز من روی برنامه‌نویسی خارج از وب هست ولی هنوز هم توی این فضا هستم و امیدوارم بتونم به بقیه کمک کنم!

سوال داری؟ برو به پنل پرسش و پاسخ

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *