کلاس Path2D قدرت جدید canvas

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

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

کارکرد این کلاس چیست؟

به طور خلاصه، این کلاس یک شکل تعریف می‌کند. شئ ساخته‌شده با این کلاس یک شکل خالی درون خود دارد، و تمام متد‌های خط که در canvas هستند، برای این شئ نیز وجود دارند؛ یعنی تمام متد‌هایی که به شکل فعلی اثر می‌گذارند، روی این شئ نیز هستند و روی این شکل اثر می‌گذارند. به نمونه کد زیر دقت کنید. در کد زیر یک شئ Path2D ایجاد شده و از متد‌های مختلف آن برای تعریف شکل استفاده می‌شود:


let path = new Path2D;

/* rect */
path.rect(10, 10, 100, 100);

/* arc */
path.arc(0, 0, 100, 0, Math.PI * 2);
path.ellipse(100, 0, 50, 25, 0, 0, Math.PI * 2);
path.arcTo(50, 50, 70, 30, 40);

/* line */
path.moveTo(0, 0);
path.lineTo(200, 200);
path.closePath();

/* curve */
path.quadraticCurveTo(180, 180, 150, 100);
path.bezierCurveTo(50, 80, 100, 80, 80, 120);

رفتار تمام متد‌های بالا مشابه متد‌های canvas است اما در اینجا، این متد‌ها به شکل درون شئ path اثر می‌گذارند. خب حال فرض کنیم یک شکل ساده تعریف کرده‌ایم. چطور می‌توانیم این شکل‌ها را رسم کنیم؟

رسم یک شکل Path2D

برای رسم شکل‌های تعریف‌شده با این کلاس باید دوباره نگاهی به ورودی‌های متد‌های fill و stroke بیاندازیم! پیش از این گفتیم که متد stroke هیچ ورودی‌ای ندارد و متد fill می‌تواند یک ورودی داشته باشد. اما این موضوع فقط برای روش قدیمی canvas درست است. در روش جدید، ورودی اول هرکدام از این متد‌ها شکل مورد‌نظر برای رسم است. کد زیر نمای کلی این متد‌ها را نشان می‌دهد:


ctx.stroke(path);
ctx.fill(path, fill_rule);

اگر ورودی path برای این متد‌ها تعریف نشود، این متد‌ها شکل فعلی درون canvas را رسم می‌کنند. به نمونه کد زیر دقت کنید. در کد زیر یک شکل Path2D تعریف شده و توسط متد fill رسم می‌شود، سپس این متد بدون شکل ورودی اجرا می‌شود که باعث رسم شکل فعلی عنصر (با شفافیت) می‌شود:


let my_path = new Path2D;

my_path.moveTo(50, 50);
my_path.lineTo(150, 150);

my_path.lineTo(150, 50);
my_path.lineTo(50, 150);

my_path.closePath();

ctx.fillStyle = "#3D3"; /* GREEN */
ctx.fill(my_path);

ctx.arc(80, 50, 40, 0, Math.PI * 2);

ctx.fillStyle = "rgba(200, 30, 200, 0.3)";
ctx.fill();

ورودی دیگر متد‌ها

به جز متد‌های fill و stroke، سه متد دیگر نیز هستند که به شکل فعلی مربوط هستند و می‌توانند یک شکل Path2D به عنوان ورودی داشته باشند. این مورد فقط به تعداد ورودی‌های این متد‌ها مربوط است و هیچ تغییری در رفتار این متد‌ها ندارد. در ادامه به هر یک از این متد‌ها می‌پردازیم.

متد clip

این متد در حالت قدیمی یک ورودی اختیاری دارد که همان fill_rule است که شبیه ورودی متد fill است و می‌تواند رفتار محدوده در شکل‌های پیچیده را مشخص کند. اگر از مقدار "evenodd" برای این ورودی استفاده شود، رفتار این متد تغییر کرده و بخش‌هایی از شکل که روی هم می‌افتند، جزء محدوده نخواهند بود. برای درک بهتر این مقدار در این متد، شکل مورد‌نظر خود را با الگوی even-odd رسم کنید. بخش‌هایی از شکل که رسم نشده‌اند، در متد clip نیز جزئی از محدوده‌ی ترسیمات نخواهند بود. علت اینکه پیش از این به این ورودی اشاره نکردیم، پیچیدگی بیش‌از‌حد آن است اما الان برای فهمیدن آن دانش و تسلط کافی دارید!

این متد در حالت جدید دو ورودی می‌پذیرد که ورودی اول همان شکل Path2D و ورودی دوم نیز fill_rule است. این متد ترسیمات canvas را به این شکل ورودی محدود می‌کند. اگر این ورودی داده نشود، از شکل فعلی خود عنصر استفاده می‌شود. به کد زیر دقت کنید. در کد زیر دو شکل تعریف می‌شوند، سپس از یکی درون متد clip و از دیگری برای رسم استفاده می‌شود:


cvs.width = cvs.height = 500;

let clip_path = new Path2D,
    draw_path = new Path2D;

clip_path.moveTo(200, 0);
clip_path.lineTo(550, 300);

clip_path.lineTo(50, 450);
clip_path.closePath();

draw_path.arc(250, 250, 200, 0, Math.PI * 2);

ctx.save();
ctx.clip(clip_path);

ctx.fillStyle = "#3D3"; /* GREEN */
ctx.fill(draw_path);

ctx.restore();

ctx.setLineDash([20]);
ctx.lineWidth = 3;

ctx.lineCap = ctx.lineJoin = "round";
ctx.stroke(draw_path);

می‌توانیم یک برنامه‌ی پیچیده‌تر بنویسیم. در کد زیر دو شکل شامل چندین دایره با مختصات و شعاع تصادفی ایجاد می‌شوند، سپس ترسیمات به یک شکل محدود شده و شکل دیگر رسم می‌شود. در کد زیر مقدار fill_rule برای متد clip برابر "evenodd" است. کد زیر را بررسی کرده و سعی کنید ویژگی‌های بیشتری به آن اضافه کنید:


function get_random (min, max) {
    return Math.random() * (max - min + 1) + min;
}

function generate_arc (path, n, min, max) {
    let i, x, y, r;
    
    for (i = 0; i < n; i++) {
        x = get_random(0, cvs.width);
        y = get_random(0, cvs.height);
        r = get_random(min, max);
        
        path.moveTo(x + r, y);
        path.arc(x, y, r, 0, Math.PI * 2);
    }
}

function random_color () {
    return `rgb(${get_random(0, 255)},
                ${get_random(0, 255)},
                ${get_random(0, 255)}`;
}

let clip_path = new Path2D,
    draw_path = new Path2D;

generate_arc(clip_path, 8, 80, 100);
generate_arc(draw_path, 40, 20, 40);

ctx.lineWidth = 2;
ctx.lineCap = ctx.lineWidth = "round";

ctx.save();
ctx.clip(clip_path, "evenodd");

ctx.fillStyle = random_color();
ctx.fill(draw_path, "evenodd");

ctx.restore();
ctx.stroke(clip_path);

ctx.setLineDash([15]);
ctx.stroke(draw_path);

canvas random clip
در کد بالا تابع get_random شبیه به توابع قبلی یک عدد تصادفی در محدوده‌ی ورودی‌هایش برمی‌گرداند، تابع random_color نیز یک رنگ تصادفی به فرمت rgb برمی‌گرداند. تابع generate_arc نیز چهار ورودی می‌پذیرد که ورودی اول شکل مورد‌نظر، ورودی دوم تعداد دایره‌ها، و ورودی‌های سوم و چهارم محدوده‌ی اندازه‌ی شعاع هستند.

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

متد isPointInPath

این متد نیز در حالت قدیمی خود سه ورودی دارد، اما در حالت جدید چهار ورودی می‌پذیرد. ورودی اول شکل مورد‌نظر، ورودی‌های دوم و سوم مختصات نقطه، و ورودی چهارم مقدار fill_rule است. نمای کلی این متد به صورت زیر است:


ctx.isPointInPath(path, x, y, fill_rule);

متد isPointInStroke

این متد نیز شبیه به متد قبل است با این تفاوت که مقدار fill_rule را ندارد. نمای کلی این متد به صورت زیر است:


ctx.isPointInStroke(path, x, y);

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


function get_random (min, max) {
    return Math.random() * (max - min + 1) + min;
}

function random_color () {
    return `rgb(${get_random(0, 255)},
                ${get_random(0, 255)},
                ${get_random(0, 255)}`;
}

function polar (n, min, max) {
    let path = new Path2D,
        angle = 2 * Math.PI / n,
        i, r, x, y;
    
    for (i = 0; i < n; i++) {
        r = get_random(min, max);
        x = r * Math.cos(i * angle);
        y = r * Math.sin(i * angle);

        path.lineTo(x, y);
    }

    path.closePath();
    return path;
}

let big_path,
    small_path;

ctx.lineWidth = 3;
ctx.lineCap = ctx.lineJoin = "round";

ctx.setLineDash([80, 20])
ctx.translate(250, 250);

function new_path () {
    big_path = polar(100, 210, 240);
    small_path = polar(50, 70, 100);
    
    draw_path();
}

function draw_path () {
    ctx.save();
    ctx.resetTransform();
    
    ctx.clearRect(0, 0, 500, 500);
    ctx.restore();
    
    ctx.fillStyle = random_color();
    ctx.fill(big_path);
    
    ctx.save();
    ctx.globalCompositeOperation = "destination-out";
    
    ctx.fill(small_path);
    ctx.restore();
    
    ctx.stroke(big_path);
    ctx.stroke(small_path);
}

function check_cursor (e) {
    let box = cvs.getBoundingClientRect(),
        
        x = e.clientX - box.left,
        y = e.clientY - box.top;
    
    if (ctx.isPointInStroke(big_path, x, y) ||
        ctx.isPointInStroke(small_path, x, y)
    ) {
        draw_path();
    }
}

cvs.addEventListener("click", new_path);
cvs.addEventListener("mousemove", check_cursor);

new_path();

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

ویژگی‌های این کلاس

اشیاء ساخته‌شده با این کلاس به جز متد‌های گفته‌شده هیچ ویژگی یا متد دیگری (به جز یک مورد) ندارند، اما رفتار آن‌ها در canvas دارای ویژگی‌هایی است که دانستن آن‌ها مهم است.

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

برتری‌های این کلاس

به جز مواردی که جزء اشکالات یا سختی‌های کار با این کلاس هستند، این کلاس برتری‌های خاص خود را دارد که نمی‌توان از آن‌ها چشم‌پوشی کرد. برای نمونه سرعت بالا از جمله این موارد است. جدا کردن شکل از حافظه‌ی canvas به کمک این کلاس باعث شده که سرعت ایجاد ترسیمات، به‌ویژه در انیمیشن‌ها، بسیار بیشتر شود و انجام بسیاری از کار‌ها نیز ممکن شود.

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

موارد خاص

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

ورودی‌های این کلاس

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


let path_from_existing = new Path2D(path),
    path_from_svg = new Path2D(d);

حالت اول این است که یک شئ Path2D دیگر به عنوان ورودی این کلاس داده شود، در این صورت یک پیوست (کپی) از شکل ورودی در شکل تازه قرار می‌گیرد. به نمونه کد زیر دقت کنید. در کد زیر ابتدا یک دایره تعریف می‌شود و در شکل old_path ذخیره می‌شود، سپس یک شکل جدید به نام new_path از روی شکل قبلی ایجاد شده و در canvas رسم می‌شود. همانطور که خواهید دید، شکل قبلی درون شکل جدید قرار گرفته است:


let old_path = new Path2D;
old_path.arc(200, 200, 100, 0, Math.PI * 2);

let new_path = new Path2D(old_path);
old_path.rect(100, 100, 200, 200);

ctx.lineWidth = 3;
ctx.setLineDash([30, 15]);
ctx.lineCap = ctx.lineJoin = "round";

ctx.fillStyle = "#3D3"; /* GREEN */
ctx.strokeStyle = "#111"; /* BLACK */

ctx.fill(new_path);
ctx.stroke(old_path);

کد بالا به نکته‌ی مهم دیگری نیز اشاره می‌کند. همانطور که می‌بینید، پس از تعریف new_path، یک مربع دیگر نیز به شکل old_path اضافه شد اما این مربع درون new_path قرار نگرفت. این یعنی شکل‌هایی که درون یکدیگر پیوست می‌شوند، فقط هنگام تعریف به هم وابسته هستند و پس از آن، تغییر در شکل قبلی، تاثیری در دیگر شکل‌ها ندارد.

در حالت دوم این کلاس، ورودی یک متن است و این متن شبیه به مقدار ویژگی d در عنصر path در SVG است! اگر به یاد داشته باشید، در آموزش شکل‌های پایه گفتیم که دستورات path در ترسیمات canvas نیز کاربرد دارد و بهتر است آن‌ها را نیز یاد بگیرید. کاربرد این دستورات در ورودی این کلاس است. به کمک این کلاس می‌توان با استاندارد SVG شکل تعریف کرد و از آن شکل درون canvas استفاده کرد.

برای نمونه به کد زیر دقت کنید. در کد زیر از دستورات متنی برای ایجاد شکل استفاده شده است. همچنین تابع polar تغییر یافته و شکلی که ایجاد می‌کند، ابتدا به دستور d تبدیل شده و سپس درون کلاس Path2D قرار می‌گیرد، البته شکلی که ایجاد می‌شود تغییری نکرده، بلکه فقط نوع ایجاد آن تغییر کرده:


function get_random (min, max) {
    return Math.random() * (max - min + 1) + min;
}

function random_color () {
    return `rgb(${get_random(0, 255)},
                ${get_random(0, 255)},
                ${get_random(0, 255)}`;
}

let a_path = new Path2D("M 50 50, 350 350, 350 50, 50 350, Z");

ctx.lineWidth = 10;
ctx.lineCap = ctx.lineJoin = "round";

ctx.strokeStyle = random_color();
ctx.stroke(a_path);

function polar (n, min, max) {
    let d = [],
        angle = Math.PI * 2 / n,
        i, r, x, y;
    
    for (i = 0; i < n; i++) {
        r = get_random(min, max);

        x = r * Math.cos(i * angle);
        y = r * Math.sin(i * angle);

        d.push(x, y);
    }

    d = "M " + d.join(" ") + " Z";
    return new Path2D(d);
}

let d_path = polar(50, 150, 170);

ctx.fillStyle = random_color();
ctx.translate(250, 250);

ctx.fill(d_path);

شاید با خود بگویید که تابع polar قبلی نیز همین کار را انجام می‌دهد. پس چه نیازی به استفاده از ورودی متن داریم؟ پاسخ در دستورات متنی است، زیرا canvas معادلی برای برخی از این دستورات ندارد و ورودی متنی در این موارد نسبت به متد‌های canvas برتری دارد.

از جمله‌ی این موارد می‌توان به دستورات S و T همراه با دستورات Q و C اشاره کرد. همانطور که در آموزش شکل‌های پایه گفتیم، در canvas متد معادلی برای این دو دستور نیست. مورد دیگری که به آن اشاره کردیم، مختصات نسبی است. گفتیم در canvas تمام مختصات مطلق هستند، اما در ورودی متنی می‌توان از مختصات نسبی شبیه به SVG استفاده کرد. ممکن است ترسیماتی داشته باشیم که یا با استاندارد SVG تعریف شده‌اند، یا مختصات نسبی دارند، یا اینکه چند خم پشت سر هم دارند و استفاده از دستورات S و T در آن‌ها بهتر است. در چنین مواردی، ورودی متنی بسیار کاربردی است.

بررسی یک نمونه

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

می‌دانیم که برای ساختن شکل‌های تصادفی، استفاده از مختصات قطبی بهتر است. در کد زیر تابع polar یک شکل تصادفی به مرکز (cx,cy) و در بازه‌ی min و max ایجاد می‌کند. از آنجایی که در مواردی لازم است اعداد تصادفی با بخش اعشاری داشته باشیم، تابع random را نیز تغییر دهید. می‌توانیم برای سادگی به جای نام get_random از نام random استفاده کنیم:


function random (min, max) {
    return ((Math.random() * (max - min + 1) * 1000 | 0) / 1000) + min;
}

function polar (n, cx, cy, min, max) {
    let vertices = [],
        angle = Math.PI * 2 / n,
        i, x, y, r, t;
    
    for (i = 0; i < n; i++) {
        r = random(min, max);
        t = i * angle;

        x = cx + r * Math.cos(t);
        y = cy + r * Math.sin(t);

        vertices.push(x, y);
    }

    return vertices;
}

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


class Vertex
{
    constructor (x, y) {
        this.x = x;
        this.y = y;

        this.speed = [
            random(-1, 1),
            random(-1, 1)
        ];
    }

    get coords () {
        return [this.x, this.y];
    }
}

Vertex.update = function (vertex) {
    vertex.x = Math.max(0, Math.min(cvs.width, vertex.x + vertex.speed[0]));
    vertex.y = Math.max(0, Math.min(cvs.height, vertex.y + vertex.speed[1]));

    if (vertex.x === 0 || vertex.x === cvs.width) vertex.speed[0] *= -1;
    if (vertex.y === 0 || vertex.y === cvs.height) vertex.speed[1] *= -1;
}

حال برای استفاده از این کلاس لازم است تابع polar را تغییر داده و یک تابع برای انیمیشن ایجاد کنیم. به این منظور تابع draw_shape را ایجاد کرده و در ادامه برنامه را برای اجرا آماده می‌کنیم:


cvs.width = cvs.height = 500;

function polar (n, cx, cy, min, max) {
    let vertices = [],
        angle = Math.PI * 2 / n,
        i, x, y, r, t;
    
    for (i = 0; i < n; i++) {
        r = random(min, max);
        t = i * angle;

        x = cx + r * Math.cos(t);
        y = cy + r * Math.sin(t);

        vertices.push(new Vertex(x, y));
    }

    return vertices;
}

function clear (context) {
    context.save();

    context.globalCompositeOperation = "copy";
    context.fillStyle = "rgba(0, 0, 0, 0)";

    context.fillRect(0, 0, 1, 1);
    context.restore();
}

function draw_shape () {
    ctx.beginPath();
    clear(ctx);

    for (let i = 0, l = vertices.length; i < l; i+=2) {
        ctx.lineTo(...vertices[i].coords);
        Vertex.update(vertices[i]);
    }

    ctx.closePath();

    ctx.fill("evenodd");
    ctx.stroke();

    requestAnimationFrame(draw_shape);
}

let vertices = polar(40, cvs.width / 2, cvs.height / 2, 200, 220);

ctx.lineCap = ctx.lineJoin = "round";
ctx.lineWidth = 5;

ctx.fillStyle = "#3D3"; /* GREEN */
draw_shape();

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


class Vertex
{
    constructor (r, t) {
        this.r = r;
        this.t = t;

        this.angle = random(0, Math.PI * 2);
        this.angle_speed = random(10, 100) / 1000;

        this.speed = random(10, 20);
        this.nr = r;
    }

    get coords () {
        return [this.r * Math.cos(this.t), this.r * Math.sin(this.t)];
    }
}

Vertex.update = function (vertex) {
    vertex.r = vertex.nr + Math.sin(vertex.angle) * vertex.speed;
    vertex.angle = (vertex.angle + vertex.angle_speed) % (Math.PI * 2);
}

function polar (n, min, max) {
    let vertices = [],
        angle = Math.PI * 2 / n,
        i, r, t;
    
    for (i = 0; i < n; i++) {
        r = random(min, max);
        t = i * angle;

        vertices.push(new Vertex(r, t));
    }

    return vertices;
}

let vertices = polar(40, 200, 220);

ctx.lineCap = ctx.lineJoin = "round";
ctx.lineWidth = 5;

ctx.fillStyle = "#3D3"; /* GREEN */
ctx.translate(cvs.width / 2, cvs.height / 2);

draw_shape();

کد خود را اصلاح کرده و نتیجه را ببینید. همانطور که می‌بینید شکل یک‌دست باقی می‌ماند اما همچنان تغییر می‌کند و حالت انیمیشنی و پویا دارد. حال به روش کار کلاس Vertex می‌پردازیم. هنگام ایجاد یک شئ با این کلاس، دو مقدار r و t وارد آن می‌شوند که شعاع و زاویه‌ی نقطه در مختصات قطبی هستند. سپس ویژگی‌های speed، angle، angle_speed به صورت تصادفی برای این نقطه ایجاد می‌شوند. ویژگی nr نیز همان مقدار شعاع را درون خود خواهد داشت.

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

پیچیده‌ترین بخش این کد، تابع update است. در این تابع، شعاع نقطه بر اساس مقدار speed و angle تغییر می‌کند. از مقدار nr به این منظور استفاده می‌شود که مقدار اولیه‌ی شعاع حفظ شود. همانطور که می‌دانید توابع sin و cos توابعی متناوب هستند، یعنی رفتارشان در بازه‌های مشخص تکرارشوند است. ما از این ویژگی تابع sin استفاده کرده و حاصل ضرب آن در speed را به شعاع اضافه کردیم. مقدار این حاصل‌ضرب در بازه‌ی [-speed,speed] است و از آنجایی که تابع sin متناوب است، مقدار شعاع نیز به صورت متناوب تغییر می‌کند، یعنی پس از مدتی به حالت اولیه برگشته و رفتار خود را تکرار می‌کند.

توابع sin و cos در بازه‌ی [0,2π] متناوب هستند پس یعنی می‌توانیم زاویه angle را در این بازه نگه داریم، بدون اینکه نتیجه تغییر کند. به همین دلیل از % (Math.PI * 2) استفاده کردیم. در هر دوره‌ی تناوب، شعاع نقطه در یک بازه‌ی مشخص تغییر می‌کند. ترکیب رفتار این نقاط در یک شکل باعث ایجاد رفتار تغییر شکل می‌شود. برای درک بهتر این موضوع، کد زیر را اجرا کنید. در کد زیر به جای رسم شکل کلی، شعاع هر نقطه رسم می‌شود. به تغییرات هر شعاع دقت کنید؛ همانطور که می‌بینید، رفتار آن‌ها تکراری و متناوب است:


function draw_shape () {
    ctx.beginPath();
    clear(ctx);

    for (let i = 0, l = vertices.length; i < l; i+=2) {
        ctx.moveTo(0, 0);
        ctx.lineTo(...vertices[i].coords);
        Vertex.update(vertices[i]);
    }
    
    ctx.stroke();
    requestAnimationFrame(draw_shape);
}

ایجاد شکل‌های خمیده

ممکن است از خود پرسیده باشید که چرا از Path2D در این کد استفاده نشد؟ آن هم در آموزشی که مربوط به این کلاس است؟! مشکلی نیست! می‌توانیم از این کلاس نیز استفاده کنیم. برای این کار تابع draw_shape را به این شکل تغییر می‌دهیم. در کد جدید این تابع، ابتدا ویژگی d تعریف شده و در نهایت درون ورودی یک شئ Path2D قرار گرفته و در نهایت این شکل رسم می‌شود:


function draw_shape () {
    clear(ctx);

    let l = vertices.length,
        d = [], i, path;

    for (i = 0; i < l; i+=2) {
        d.push(...vertices[i].coords);
        Vertex.update(vertices[i]);
    }

    d = "M " + d.join(" ") + " Z";
    path = new Path2D(d);

    ctx.fill(path);
    ctx.stroke(path);

    requestAnimationFrame(draw_shape);
}

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

برای انجام این کار باید درک خوبی از خم‌ها و روش کار دستور S داشته باشیم. برای اینکه خم‌ها به درستی روی نقاط آرایه ایجاد شوند، باید نقاط دیگری نیز وارد معادله کنیم، این نقاط، بین نقاط آرایه قرار دارند. از آنجایی که این نقاط فقط برای ایجاد شکل استفاده می‌شوند و کاربرد دیگری ندارند، فقط به مختصات آن‌ها نیاز داریم، بنابراین می‌توانیم متد middle را به کلاس Vertex اضافه کنیم. این متد مختصات میان دو نقطه‌ی ورودی خود را برمی‌گرداند:


Vertex.middle = function (v1, v2) {
    let c1 = v1.coords,
        c2 = v2.coords;
    
    return [(c1[0] + c2[0]) / 2, (c1[1] + c2[1]) / 2];
}

همچنین برای رسم خم، یک تابع جدید ایجاد می‌کنیم. این تابع شبیه به تابع draw_shape است و نام آن را draw_curve می‌گذاریم. شکل کلی این تابع به صورت زیر است. هدف نهایی ما این است که یک خم یک‌دست و پیوسته با این نقاط، به کمک دستور S، ایجاد کنیم:


function draw_curve () {
    clear(ctx);

    let l = vertices.length,
        d = [], i, path;

    /* (*) */

    for (i = 0; i < l; i++) {
        /* DRAW CURVE */
        Vertex.update(vertices[i]);
    }

    d = "M " + d.join(" S ");
    path = new Path2D(d);

    ctx.fill(path);
    ctx.stroke(path);

    requestAnimationFrame(draw_curve);
}

در کد بالا، به جای توضیحات درون حلقه، باید دستورات مناسب را وارد d کنیم. در ادامه و پس از پایان حلقه، دستورات درون d به شکل یک متن استاندارد تبدیل شده و یک شکل با آن‌ها ساخته می‌شود. در نهایت این شکل در canvas رسم می‌شود. از آنجایی که نقاط خم باید به هم برسند، نیازی به استفاده از دستور Z در انتهای d نیست.

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


for (i = 0; i < l; i++) {
    d.push(vertices[i].coords.join(" ") + " " + Vertex.middle(vertices[i], vertices[(i + 1) % l]).join(" "));
    Vertex.update(vertices[i]);
}

یک پرسش مهم که با دیدن کد بالا ایجاد می‌شود، این است که چرا به جای i + 1 از عبارت (i + 1) % l استفاده شد؟ می‌دانیم که باید مختصات میان هر نقطه، با نقطه‌ی پس از آن محاسبه شود. خب، وقتی حلقه به مرحله‌ی i = l - 1 می‌رسد، یعنی حلقه به آخرین نقطه رسیده، و باید مختصات بین نقطه‌ی آخر و نقطه‌ی اول محاسبه شود. خب اندیس نقطه‌ی اول در آرایه 0 است. وقتی از عبارت (i + 1) % l استفاده کنیم و i = l - 1 باشد، عبارت (i + 1) % l برابر صفر می‌شود، یعنی همان اندیس نقطه‌ی اول! علت استفاده از این عبارت همین است. همچنین اگر از این عبارت استفاده نشود با خطا مواجه خواهید شد، اگر به ساختار کد دقت کنید علت آن بدیهی است!

خب برنامه را اجرا کنید. همانطور که می‌بینید یک خم یک‌دست و پیوسته ایجاد شده که در حال تغییر است. به جز یک نقطه! این نقطه همان نقطه‌ی اولیه‌ی خم است که یک لبه‌ی تیز ایجاد کرده. برای حل این مشکل لازم است که مختصات قلم (به جای توضیحات * پیش از آغاز حلقه) به مختصات بین نقطه‌ی آخر و اول برده شود. بنابراین پیش از اجرای حلقه، این کد را به تابع اضافه می‌کنیم:


d.push(Vertex.middle(vertices[l - 1], vertices[0]));

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


function draw_curve () {
    clear(ctx);

    let l = vertices.length,
        d = [], i, path;

    d.push(Vertex.middle(vertices[l - 1], vertices[0]));

    for (i = 0; i < l; i++) {
        d.push(vertices[i].coords.join(" ") + " " + Vertex.middle(vertices[i], vertices[(i + 1) % l]).join(" "));
        Vertex.update(vertices[i]);
    }

    d = "M " + d.join(" S ");
    path = new Path2D(d);

    ctx.fill(path);
    ctx.stroke(path);

    requestAnimationFrame(draw_curve);
}

در شکل زیر سه مجموعه شکل به کمک توابع draw_shape و draw_curve رسم شده‌اند. با وجود اینکه هر دوی این شکل‌ها حالت انیمیشنی مناسبی دارند، ولی همانطور که می‌بینید، شکل‌های رسم‌شده به کمک دستور S (سمت چپ) حالت بسیار بهتری دارند، حتی با وجود اینکه ویژگی lineJoin برای شکل سمت راست به round تغییر کرده، ولی باز هم نسبت به خم‌های سمت چپ کیفیت کمتری دارند.

canvas shape curve

نمونه‌ی بالا شاید کاربرد چندانی نداشته باشد، اما از این جهت بررسی شد تا کاربرد دستورات path را نشان دهد. استفاده از این دستورات به جای متد‌ها می‌تواند در موارد مشابه ما را از دردسر‌های زیادی نجات دهد! اگر هنوز با دستورات path آشنایی ندارید، بهتر است یک حداقل آشنایی با این دستورات پیدا کنید، به‌ویژه به این دلیل که با متد‌های مشابه در canvas سر و کار دارید. در زیر می‌توانید یک نمونه‌ی پیشرفته‌تر از مدل بالا را ببینید:

See the Pen
Canvas Cells
by Hossein Rafie (@Hossein_Rafie)
on CodePen.

متد addPath

آخرین متد اشیاء کلاس Path2D متد addPath است. این متد به اندازه‌ی خود این کلاس دارای اهمیت است! به کمک این متد می‌توان یک شئ Path2D دیگر را به شکل خود اضافه کنیم. این متد فقط یک ورودی از نوع Path2D دریافت می‌کند. به کد زیر دقت کنید. در کد زیر ابتدا شکل main_path ایجاد شده، و سپس به کمک متد addPath، یک شکل دیگر به آن اضافه می‌شود:


let main_path = new Path2D;
main_path.arc(200, 200, 100, 0, Math.PI * 2);

let additional = new Path2D("M 100 100, H 100, V 100, H -100, Z");
main_path.addPath(additional);

ctx.lineWidth = 5;
ctx.stroke(main_path);

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

بررسی چند نمونه

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

خم پیوسته با نشانگر

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


cvs.width = cvs.height = 500;

let path = new Path2D,
    str = `M ${cvs.width / 2} ${cvs.height / 2}`,

    dash_array = [40, 20], offset = 0,
    max_offset = dash_array.reduce((a, b) => a + b);

function random (min, max) {
    return ((Math.random() * (max - min + 1) * 1000 | 0) / 1000) + min;
}

function clear (context) {
    context.save();
    
    context.globalCompositeOperation = "copy";
    context.fillStyle = "rgba(0, 0, 0, 0)";

    context.fillRect(0, 0, 1, 1);
    context.restore();
}

function add_curve (e) {
    let box = cvs.getBoundingClientRect(),

        x1 = e.clientX - box.left,
        y1 = e.clientY - box.top,

        x2 = random(0, cvs.width),
        y2 = random(0, cvs.height);
    
    str += ` S ${x2} ${y2} ${x1} ${y1}`;
    path = new Path2D(str);
}

function draw_path () {
    clear(ctx);

    offset = ++offset % max_offset;
    ctx.lineDashOffset = -offset;

    ctx.stroke(path);
    requestAnimationFrame(draw_path);
}

ctx.setLineDash(dash_array);
ctx.lineCap = ctx.lineWidth = "round";

ctx.lineWidth = 3;
ctx.strokeStyle = "#111";

cvs.addEventListener("click", add_curve);
requestAnimationFrame(draw_path);

طرح مارپیچ

در این برنامه با هر حرکت نشانگر در صفحه، یک مارپیچ به کمک کلاس Spiral ایجاد می‌شود. این کلاس مارپیچ‌هایی ایجاد می‌کند که حرکتشان در یک مختصات تصادفی آغاز شده و به مرور چرخیده و کوچک می‌شوند. روش کار این برنامه شبیه به دایره‌های سرگردان است که در بخش *آموزش انیمیشن* بررسی شد:


cvs.width = 1200;
cvs.height = 700;

function random (min, max) {
    return ((Math.random() * (max - min + 1) * 1000 | 0) / 1000) + min;
}

function random_color () {
    return `rgb(${random(0, 255)},
                ${random(0, 255)},
                ${random(0, 255)}`;
}

class Spiral
{
    constructor (x, y) {
        this.x = x + random(-offset, offset);
        this.y = y + random(-offset, offset);

        this.angle = Math.random() * Math.PI * 2;
        this.angle_speed = random(10, 20) / 15;
        
        this.size = random(5, 20);
        this.size_rate = this.size / 30;

        this.speed = this.size;
        this.color = random_color();

        this.status = true;
        this.angle_speed *= ((Math.random() * 2 | 0) === 0) ? 1 : -1;
    }

    draw () {
        if (!this.status) return;
        ctx.save();

        ctx.translate(this.x, this.y);
        ctx.scale(this.size, this.size);

        ctx.fillStyle = this.color;
        ctx.fill(path);

        ctx.restore();
    }

    update () {
        if (this.size === 0) {
            this.status = false;
            return;
        }

        this.x += this.speed * Math.cos(this.angle);
        this.y += this.speed * Math.sin(this.angle);

        this.angle += this.angle_speed;
        this.size = Math.max(0, this.size - this.size_rate);

        this.speed -= 0.1;
    }
}

function loop () {
    ctx.save();

    ctx.fillStyle = "rgba(0, 0, 0, 0.05)";
    ctx.fillRect(0, 0, cvs.width, cvs.height);

    ctx.restore();

    for (let i = spirals.length - 1; i >= 0; i--) {
        if (spirals[i].status === false) {
            spirals.splice(i, 1);
            continue;
        }

        spirals[i].draw();
        spirals[i].update();
    }

    let x = random(0, cvs.width),
        y = random(0, cvs.height);

    if (spirals.length < 30) { spirals.push(new Spiral(x, y)); }

    requestAnimationFrame(loop);
}

let lerp = 0.15,
    offset = 50,
    
    spirals = [],
    path = new Path2D;

path.arc(0, 0, 1, 0, Math.PI * 2);
loop();

ترکیب رنگ‌ها

در این برنامه به کمک کلاس Line خطوطی با رنگ و ضخامت تصادفی ایجاد می‌شوند و در آرایه‌ی lines قرار می‌گیرند. نقطه‌ی اصلی این خطوط در هر بار اجرای متد draw به یک مختصات تصادفی حرکت کرده و خط حاصل از این حرکت رسم می‌شود. با هر کلیک کاربر روی عنصر، تعدادی خط در آن نقطه ایجاد می‌شوند، البته اگر تعداد خطوط به max رسیده باشد، دیگر خطی به برنامه اضافه نمی‌شود. همچنین به کمک عملگر % در انتهای متد draw از خارج شدن خطوط از صفحه جلوگیری می‌کنیم، یعنی اگر خطی از یک طرف عنصر خارج شود، در آن طرف عنصر ظاهر می‌شود:


cvs.width = 1200;
cvs.height = 700;

function random (min, max) {
    return ((Math.random() * (max - min + 1) * 1000 | 0) / 1000) + min;
}

function random_color () {
    return `rgb(${random(0, 255)},
                ${random(0, 255)},
                ${random(0, 255)}`;
}

class Line
{
    constructor (x, y) {
        this.x = x;
        this.y = y;

        this.color = random_color();
        this.width = random(1, 3);
    }

    draw () {
        ctx.save();
        ctx.beginPath();

        ctx.moveTo(this.x, this.y);

        this.x += (random(0, 6) - 3.5) * 2;
        this.y += (random(0, 6) - 3.5) * 2;

        ctx.lineTo(this.x, this.y);

        ctx.lineWidth = this.width;
        ctx.strokeStyle = this.color;

        ctx.stroke();
        ctx.restore();

        this.x = (this.x + cvs.width) % cvs.width;
        this.y = (this.y + cvs.height) % cvs.height;
    }
}

let lines = [],
    count = 300,
    max = 400;

function init_lines () {
    for (let i = 0; i < count; i++) {
        lines[i] = new Line(random(0, cvs.width), random(0, cvs.height));
    }
}

function add_line (e) {
    let box = cvs.getBoundingClientRect(),

        x = e.clientX - box.left,
        y = e.clientY - box.top;
    
    for (let i = 0; i < count / 10; i++) {
        if (lines.length < max) lines.push(new Line(x, y)); } } function draw_lines () { lines.forEach((line) => line.draw());
    requestAnimationFrame(draw_lines);
}

init_lines();
draw_lines();

cvs.addEventListener("click", add_line);

canvas colored lines

شبکه‌ی ذرات

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


cvs.width = 1200;
cvs.height = 700;

function random (min, max) {
    return ((Math.random() * (max - min + 1) * 1000 | 0) / 1000) + min;
}

class Particle
{
    constructor () {
        this.x = random(0, cvs.width);
        this.y = random(0, cvs.height);

        this.speed = [
            random(0, speed * 2) - speed,
            random(0, speed * 2) - speed
        ];
    }

    draw (path) {
        path.moveTo(this.x + radius, this.y);
        path.arc(this.x, this.y, radius, 0, Math.PI * 2);

        this.x += this.speed[0];
        this.y += this.speed[1];

        if (this.x < -radius) this.x = cvs.width + radius;
        if (this.x > cvs.width + radius) this.x = -radius;

        if (this.y < -radius) this.y = cvs.height + radius;
        if (this.y > cvs.height + radius) this.y = -radius;
    }
}

Particle.distance = function (p1, p2) {
    return ((p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2) ** 0.5;
}

let background = "#111",
    foreground = "#CC0",

    count = 200,
    particles = [],
    distance = 80,
    
    speed = 1,
    radius = 5;

function init_particles () {
    for (let i = 0; i < count; i++) {
        particles[i] = new Particle;
    }
}

function draw_particles () {
    ctx.fillStyle = background;
    ctx.fillRect(0, 0, cvs.width, cvs.height);
    
    let fill_path = new Path2D,
        stroke_path = new Path2D,

        i, j;
    
    for (i = 0; i < count; i++) {
        particles[i].draw(fill_path);

        for (j = i + 1; j < count; j++) {
            if (Particle.distance(particles[i], particles[j]) < distance) {
                stroke_path.moveTo(particles[i].x, particles[i].y);
                stroke_path.lineTo(particles[j].x, particles[j].y);
            }
        }
    }

    ctx.fillStyle = foreground;

    ctx.stroke(stroke_path);
    ctx.fill(fill_path);

    requestAnimationFrame(draw_particles)
}

ctx.lineWidth = 3;
ctx.strokeStyle = foreground;

init_particles();
draw_particles();

canvas particles

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

See the Pen
Canvas Particles
by Hossein Rafie (@Hossein_Rafie)
on CodePen.

نتیجه‌گیری

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

حسین رفیعی

حسین رفیعی

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

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

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