اقتباسإنني أنظر إلى هذه الألوان التي أمامي، ثم أنظر إلى اللوحة البيضاء، ثم أحاول وضع الألوان عليها كما يضع الشاعر كلماته في قصيدته تمامًا.
ـــ يوان ميرو Joan Miro.
سنبني في هذا المقال تطبيق ويب بناءً على ما درسناه في المقالات السابقة وسيكون هذا التطبيق للرسم بأسلوب البكسلات، حيث يمكنك أخذ رؤية مكبرة من الصورة المنفردة وتغيير أو تعديل كل بكسل فيها، كما يمكنك فتح الصورة بالبرنامج وتعديلها بالفأرة أو أيّ أداة تأشير أخرى ثم حفظها، حيث سيبدو البرنامج هكذا:
المكونات
تُظهر واجهة البرنامج عنصر <canvas>
كبيرًا في الأعلى مع عدد من حقول الاستمارات form fields أسفله، ويرسم المستخدِم على الصورة عبر اختيار أداة من حقل <select>
ثم ينقر عليه أو يدهن أو يسحب المؤشر في لوحة الرسم، كما هناك أدوات لرسم بكسلات منفردة أو مستطيلات ولملء مساحة ما باللون ولالتقاط لون ما من الصورة.
سنبني هيكل المحرِّر على أساس عدد من المكونات والكائنات التي تكون مسؤولة عن جزء من تمثيل كائن المستند DOM وقد تحتوي مكونات أخرى داخلها، كما تتكون حالة التطبيق من الصورة الحالية والأداة المختارة واللون المختار كذلك، حيث سنضبط هذه المتغيرات كي تكون الحالة داخل قيمة واحدة، كما تبني مكونات الواجهة مظهرها دائمًا على الحالة الراهنة.
دعنا ننظر في بديل ذلك كي نرى أهميته من خلال توزيع أجزاء من الحالة على الواجهة، وهذا سهل على البرنامج حتى نقطة ما، حيث نستطيع وضع حقل اللون ونقرأ قيمته عندما نريد معرفة اللون الحالي، لكن نضيف هنا منتقي الألوان color picker الذي يُعَدّ الأداة التي تسمح لك بالنقر على الصورة لاختيار اللون من بكسل ما، ولكي يظل حقل اللون مُظهرًا اللون الصحيح يجب على هذه الأداة معرفة أنه موجود وتحدِّثه كلما اختارت لونًا جديدًا، فإذا أضفت مكانًا آخرًا يجعل اللون مرئيًا بحيث يستطيع مؤشر الفأرة إظهاره مثلًا، فعليك تحديث شيفرة تغيير اللون لديك كي تبقى متزامنة.
لكن في الواقع يخلق هذا مشكلةً بحيث يحتاج كل جزء من الواجهة إلى معرفة جميع الأجزاء الأخرى، وهذا ليس عمليًا إذ سيجعل البرنامج أقل مرونة في التعديل عليه، لكن لا يمثِّل هذا مشكلةً بالنسبة لبرنامج صغير مثل برنامجنا؛ أما في المشاريع الكبيرة فسيكون هذا كارثة حقيقية، ولكي نتجنب هذا الكابوس وإن كان من حيث المبدأ في مثالنا فسنكون حازمين بشأن تدفق البيانات، فهناك حالة تُرسم الواجهة وفقًا لها، وقد يستجيب مكوِّن الواجهة لإجراءات المستخدِم عبر تحديث الحالة، بحيث تحصل المكونات عندئذ على فرصة لمزامنة أنفسها مع هذه الحالة الجديدة.
ضُبِط كل مكون من الناحية العملية ليُخطر عناصره الفرعية بإشعار كلما أُعطي حالةً جديدةً بالقدر الذي تحتاج إليه، لكن يُعَدّ ضبط ذلك أمرًا متعبًا، كما تفضِّل المتصفحات كثيرًا المكتبات البرمجية التي تسهل ذلك، لكن نستطيع إنشاء برنامج صغير مثل هذا بدون هذه البنية التحتية، كما تمثَّل التحديثات على الحالة على أساس كائنات سنطلق عليها إجراءات، وقد تنشئ المكونات مثل هذه الإجراءات وترسلها بسرعة إلى دالة مركزية لإدارة الحالة، حيث تحسب هذه الدالة الحالة التالية ثم تحدِّث مكونات الواجهة أنفسها إليها.
نأخذ بهذا مهمة تشغيل واجهة المستخدِم ونضيف إليها بعض الهيكلة، كما تحتفظ الأجزاء المتعلقة بـ DOM بما يشبه العمود الفقري رغم أنها ملأى بالآثار الجانبية، إذ يُعَدّ هذا العمود دورة تحديث الحالة، كما تحدِّد الحالة كيف سيبدو DOM، ولا توجد طريقة تستطيع أحداث DOM تغيير الحالة بها إلا عبر إرسال الإجراءات إلى الحالة، كما توجد هناك صور عدة لهذا المنظور ولكل منها منافعه ومساوئه، لكن الفكرة الرئيسية لها واحدة وهي أنّ تغيرات الحالة يجب عليها المرور على قناة واحدة معرَّفة جيدًا بدلًا من كونها في كل مكان.
ستكون مكوناتنا أصنافًا مطابقةً للواجهة، ويُعطى بانيها حالةً قد تكون حالة البرنامج كله أو قيمةً أصغر إذا لم يحتج الوصول إلى كل شيء، حيث يستخدِم هذا في بناء خاصية dom
، ويُعَدّ هذا عنصر DOM الذي سيمثِّل المكوِّن، كما ستأخذ أغلب المنشئات قيمًا أخرى أيضًا لا تتغير مع الوقت مثل الدالة التي تستطيع استخدامها لإرسال إجراء ما، ويملك كل مكوِّن تابعًا syncState
يُستخدَم لمزامنته مع قيمة الحالة الجديدة، حيث يأخذ التابع وسيطًا واحدًا وهو الحالة التي تكون من نوع الوسيط الأول نفسه لبانيها.
الحالة
ستكون حالة التطبيق كائنًا له الخاصيات picture
وtool
وcolor
، كما ستكون الصورة نفسها كائنًا يخزِّن العرض والارتفاع ومحتوى البكسل للصورة، في حين تُخزَّن البكسلات في مصفوفة ثنائية عبر طريقة صنف المصفوفة matrix نفسها من مقال الحياة السرية للكائنات في جافاسكريبت صفًا صفًا من الأعلى حتى الأسفل.
class Picture { constructor(width, height, pixels) { this.width = width; this.height = height; this.pixels = pixels; } static empty(width, height, color) { let pixels = new Array(width * height).fill(color); return new Picture(width, height, pixels); } pixel(x, y) { return this.pixels[x + y * this.width]; } draw(pixels) { let copy = this.pixels.slice(); for (let {x, y, color} of pixels) { copy[x + y * this.width] = color; } return new Picture(this.width, this.height, copy); } }
نريد التمكّن من معاملة الصورة على أنها قيمة غير قابلة للتغير immutable لأسباب سنعود إليها لاحقًا في هذا المقال، لكن قد نحتاج أحيانًا إلى تحديث مجموعة بكسلات في الوقت نفسه أيضًا، ولكي نفعل ذلك فإن الصنف له تابع draw
يتوقع مصفوفةً من البكسلات المحدَّثة، إذ تكون كائنات لها خاصيات x
وy
وcolor
، كما ينشئ صورةً جديدةً مغيّرًا بها هذه البكسلات، ويستخدِم ذلك التابع slice
دون وسائط لنسخ مصفوفة البسكلات كلها، بحيث تكون البداية الافتراضية لـ slice هي 0 والنهاية الافتراضية هي طول المصفوفة.
يستخدِم التابع empty
جزأين من وظائف المصفوفة لم نرهما من قبل، فيمكن استدعاء باني Array
بعدد لإنشاء مصفوفة فارغة بطول محدَّد، ثم يمكن استخدام التابع fill
بعدها لملء هذه المصفوفة بقيمة معطاة، وتُستخدَم هذه الأجزاء لإنشاء مصفوفة تحمل كل البكسلات فيه اللون نفسه.
تُخزن الألوان على أساس سلاسل نصية تحتوي على رموز ألوان CSS العادية، وهي التي تبدأ بعلامة الشباك #
متبوعة بستة أرقام ست-عشرية، بحيث يكون اثنان فيها للمكون الأحمر واثنان للأخضر واثنان للأزرق، وقد يكون هذا مبهمًا نوعًا ما، لكنها الصيغة التي تستخدمِها HTML في حقول ألوانها، حيث يمكن استخدامها في خاصية fillStyle
لسياق لوحة رسم، وهي كافية لنا في هذا البرنامج، كما يُكتب اللون الأسود أصفارًا على الصورة #000000
، ويبدو اللون الوردي الزاهي هكذا #ff00ff
بحيث تكون مكونات اللونين الأحمر والأزرق لها القيمة العظمى عند 255، فتُكتب ff
بالنظام الست-عشري الذي يستخدِم a حتى f لتمثيل الأعداد من 10 حتى 15.
سنسمح للواجهة بإرسال الإجراءات على أساس كائنات لها خاصيات تتجاوز خاصيات الحالة السابقة، ويرسل حقل اللون كائنًا حين يغيره المستخدِم مثل {color: field.value}
تحسِب منه دالة التحديث حالةً جديدةً.
function updateState(state, action) { return Object.assign({}, state, action); }
يُعَدّ استخدام Object.assign
لإضافة خصائص state
إلى كائن فارغ أولًا ثم تجاوز بعضها بخصائص من action
، استخدامًا شائعًا في شيفرات جافاسكربت التي تستخدِم كائنات غير قابلة للتغير على صعوبته في التعامل معه، والأسهل من ذلك استخدام عامِلًا ثلاثي النقاط لتضمين جميع الخصائص من كائن آخر في تعبير الكائن، وذلك لا زال بعد في مراحل اعتماده الأخيرة، وإذا تم فسوف تستطيع كتابة {...state, ...action}
بدلًا من ذلك، لكن هذا لم بثبت عمله بعد في جميع المتصفحات.
بناء DOM
أحد الأمور التي تفعلها مكونات الواجهة هي إنشاء هيكل DOM، كما سنقدم نسخة موسعة قليلًا من دالة elt
لأننا لا نريد استخدام توابع DOM في ذلك:
function elt(type, props, ...children) { let dom = document.createElement(type); if (props) Object.assign(dom, props); for (let child of children) { if (typeof child != "string") dom.appendChild(child); else dom.appendChild(document.createTextNode(child)); } return dom; }
الفرق الأساسي بين هذه النسخة والتي استخدمناها في مقال مشروع لعبة منصة باستخدام جافاسكربت أنها تسند خاصيات إلى عقد DOM وليس سمات، ويعني هذا أننا لا نستطيع استخدامها لضبط سمات عشوائية، لكن نستطيع استخدامها لضبط خاصيات قيمها ليست سلاسل نصية مثل onclick
والتي يمكن تعيينها إلى دالة لتسجيل معالج حدث نقرة، وهذا يسمح بالنمط التالي من تسجيل معالِجات الأحداث:
<body> <script> document.body.appendChild(elt("button", { onclick: () => console.log("click") }, "The button")); </script> </body>
اللوحة Canvas
يُعَدّ جزء الواجهة الذي يعرض الصورة على أساس شبكة من الصناديق المربعة المكوّن الأول الذي سنعرِّفه، وهذا الجزء مسؤول عن أمرين فقط هما عرض showing الصورة وتوصيل أحداث المؤشر التي على هذه الصورة إلى بقية التطبيق، وبالتالي يمكننا تعريفه على أساس مكوِّن لا يطلع إلا على الصورة الحالية وليس له شأن بحالة التطبيق كاملًا، كما لا يستطيع إرسال إجراءات مباشرةً لأنه لا يعرف كيف يعمل التطبيق، وأنما يستدعي دالة رد نداء callback function توفرها الشيفرة التي أنشأته حين يستجيب لأحداث المؤشر بحيث تعالِج أجزاء التطبيق على حدة.
const scale = 10; class PictureCanvas { constructor(picture, pointerDown) { this.dom = elt("canvas", { onmousedown: event => this.mouse(event, pointerDown), ontouchstart: event => this.touch(event, pointerDown) }); this.syncState(picture); } syncState(picture) { if (this.picture == picture) return; this.picture = picture; drawPicture(this.picture, this.dom, scale); } }
سنرسم كل بكسل على أساس مربع بعداه 10*10 كما هو مُحدَّد في ثابت scale
، ثم يحتفظ المكوِّن بصورته الحالية ولا يعيد الرسم إلا حين تُعطى syncState
صورةً جديدةً، كما تضبط دالة الرسم الفعلية حجم اللوحة وفقًا لمقياس الصورة وحجمها، ثم تملؤها بسلسلة من المربعات يمثِّل كل منها بكسلًا واحدًا.
function drawPicture(picture, canvas, scale) { canvas.width = picture.width * scale; canvas.height = picture.height * scale; let cx = canvas.getContext("2d"); for (let y = 0; y < picture.height; y++) { for (let x = 0; x < picture.width; x++) { cx.fillStyle = picture.pixel(x, y); cx.fillRect(x * scale, y * scale, scale, scale); } } }
إذا ضغطنا زر الفأرة الأيسر أثناء وجود الفأرة فوق لوحة الصورة فيستدعي المكون رد نداء pointerDown
ليعطيه موضع البكسل الذي تم النقر عليه في إحداثيات الصورة، حيث سيُستخدم هذا لتنفيذ تفاعل الفأرة مع الصورة، وقد يعيد رد النداء دالة رد نداء أخرى لتُنبَّه بإشعار حين يتحرك المؤشر إلى بكسل آخر أثناء الضغط على الزر.
PictureCanvas.prototype.mouse = function(downEvent, onDown) { if (downEvent.button != 0) return; let pos = pointerPosition(downEvent, this.dom); let onMove = onDown(pos); if (!onMove) return; let move = moveEvent => { if (moveEvent.buttons == 0) { this.dom.removeEventListener("mousemove", move); } else { let newPos = pointerPosition(moveEvent, this.dom); if (newPos.x == pos.x && newPos.y == pos.y) return; pos = newPos; onMove(newPos); } }; this.dom.addEventListener("mousemove", move); }; function pointerPosition(pos, domNode) { let rect = domNode.getBoundingClientRect(); return {x: Math.floor((pos.clientX - rect.left) / scale), y: Math.floor((pos.clientY - rect.top) / scale)}; }
بما أننا نعرف حجم البكسلات ونستطيع استخدام getBoundingClientRect
في إيجاد موضع اللوحة على الشاشة، فمن الممكن الذهاب من إحداثيات حدث الفأرة clientX
وclientY
إلى إحداثيات الصورة، وتُقرَّب هذه دومًا كي تشير إلى بكسل بعينه؛ أما بالنسبة لأحداث اللمس فيتوجب علينا فعل شيء قريب من ذلك لكن باستخدام أحداث مختلفة والتأكد أننا نستدعي preventDefault
على حدث "touchstart"
لمنع التمرير العمودي أو الأفقي panning.
PictureCanvas.prototype.touch = function(startEvent, onDown) { let pos = pointerPosition(startEvent.touches[0], this.dom); let onMove = onDown(pos); startEvent.preventDefault(); if (!onMove) return; let move = moveEvent => { let newPos = pointerPosition(moveEvent.touches[0], this.dom); if (newPos.x == pos.x && newPos.y == pos.y) return; pos = newPos; onMove(newPos); }; let end = () => { this.dom.removeEventListener("touchmove", move); this.dom.removeEventListener("touchend", end); }; this.dom.addEventListener("touchmove", move); this.dom.addEventListener("touchend", end); };
لا تكون أحداث clientX
وclientY
متاحةً مباشرةً لأحداث اللمس على كائن الحدث، لكن نستطيع استخدام إحداثيات كائن اللمس الأول في خاصية touches
.
التطبيق
سننفذ المكون الأساسي على أساس صدَفة حول لوحة الصورة كي نبني التطبيق جزءًا جزءًا مع مجموعة من الأدوات والمتحكمات التي نمررها لبانيه، كما ستكون المتحكمات عناصر الواجهة التي ستظهر تحت الصورة، وستكون متاحةً في صورة مصفوفة من بواني المكونات.
تفعل الأدوات مهامًا مثل رسم البكسلات أو ملء مساحة ما، ويعرض التطبيق مجموعةً من الأدوات المتاحة مثل حقل <select>
، كما تحدِّد الأداة المختارة حاليًا ما يحدث عندما يتفاعل المستخدِم مع الصورة بأداة تأشير مثل الفأرة، وتوفَّر مجموعة من الأدوات المتاحة على أساس كائن ينظِّم الأسماء التي تظهر في الحقل المنسدل للدوال التي تنفِّذ الأدوات، كما تحصل مثل هذه الدوال على موضع الصورة وحالة التطبيق الحالية ودالة dispatch
في هيئة وسائط، وقد تعيد دالة معالِج حركة move handler تُستدعى مع موضع جديد وحالة حالية عندما يتحرك المؤشر إلى بكسل جديد.
class PixelEditor { constructor(state, config) { let {tools, controls, dispatch} = config; this.state = state; this.canvas = new PictureCanvas(state.picture, pos => { let tool = tools[this.state.tool]; let onMove = tool(pos, this.state, dispatch); if (onMove) return pos => onMove(pos, this.state); }); this.controls = controls.map( Control => new Control(state, config)); this.dom = elt("div", {}, this.canvas.dom, elt("br"), ...this.controls.reduce( (a, c) => a.concat(" ", c.dom), [])); } syncState(state) { this.state = state; this.canvas.syncState(state.picture); for (let ctrl of this.controls) ctrl.syncState(state); } }
يستدعي معالج المؤشر المعطى لـ PictureCanvas
الأداة المختارة حاليًا باستخدام الوسائط المناسبة، وإذا أعاد معالج حركة فسيكيّفه ليستقبل الحالة، وتُنشأ جميع المتحكمات وتُخزَّن في this.controls
كي يمكن تحديثها حين تتغير حالة التطبيق، ويدخل استدعاء reduce
مسافات بين عناصر متحكمات DOM كي لا تبدو هذه العناصر مكثَّفة بجانب بعضها، كما تُعَدّ قائمة اختيار الأدوات أول متحكم، وتنشئ عنصر <select>
مع خيار لكل أداة وتضبط معالِج حدث "change"
الذي يحدِّث حالة التطبيق حين يختار المستخدِم أداةً مختلفةً.
class ToolSelect { constructor(state, {tools, dispatch}) { this.select = elt("select", { onchange: () => dispatch({tool: this.select.value}) }, ...Object.keys(tools).map(name => elt("option", { selected: name == state.tool }, name))); this.dom = elt("label", null, "? Tool: ", this.select); } syncState(state) { this.select.value = state.tool; } }
حين نغلِّف نص العنوان label text والحقل داخل عنصر <label>
فإننا نخبر المتصفح أن العنوان ينتمي إلى هذا الحقل كي تستطيع النقر مثلًا على العنوان لتنشيط الحقل، كذلك نحتاج إلى إمكانية تغيير اللون، لذا سنضيف متحكمًا لهذا وهو عنصر <input>
من HTML مع سمة type
لـ color
، بحيث تعطينا حقل استمارة مخصص لاختيار الألوان، وقيمةً مثل هذا الحقل تكون دائمًا رمز لون CSS بصيغة "#RRGGBB"
أي الأحمر ثم الأخضر ثم الأزرق بمعنى رقمين لكل لون، وسيعرض المتصفح واجهة مختار الألوان color picker عندما يتفاعل المستخدِم معها، كما ينشئ هذا المتحكم مثل ذلك الحقل ويربطه ليكون متزامنًا مع خاصية color
الخاصة بحالة التطبيق.
class ColorSelect { constructor(state, {dispatch}) { this.input = elt("input", { type: "color", value: state.color, onchange: () => dispatch({color: this.input.value}) }); this.dom = elt("label", null, "? Color: ", this.input); } syncState(state) { this.input.value = state.color; } }
أدوات الرسم
نحتاج قبل رسم أي شيء إلى تنفيذ الأدوات التي ستتحكم في وظائف الفأرة وأحداث اللمس على اللوحة، وأبسط أداة هي أداة الرسم التي تغير أيّ بكسل تنقر عليه أو تلمسه بإصبعك إلى اللون الحالي، وترسل إجراءً يحدِّث الصورة إلى إصدار يُعطى فيه البكسل المشار إليه اللون المختار الحالي.
function draw(pos, state, dispatch) { function drawPixel({x, y}, state) { let drawn = {x, y, color: state.color}; dispatch({picture: state.picture.draw([drawn])}); } drawPixel(pos, state); return drawPixel; }
تستدعي الدالة فورًا دالة drawPixel
ثم تعيدها كي تُستدعى مرةً أخرى من أجل البكسلات التي ستُلمَس لاحقًا حين يسحب المستخدِم إصبعه أو يمرره على الصورة، ولكي نرسم أشكالًا أكبر فمن المفيد إنشاء مستطيلات بسرعة، كما ترسم أداة rectangle
مستطيلًا بين النقطة التي تبدأ السحب منها حتى النقطة التي تترك فيها المؤشر أو ترفع إصبعك.
function rectangle(start, state, dispatch) { function drawRectangle(pos) { let xStart = Math.min(start.x, pos.x); let yStart = Math.min(start.y, pos.y); let xEnd = Math.max(start.x, pos.x); let yEnd = Math.max(start.y, pos.y); let drawn = []; for (let y = yStart; y <= yEnd; y++) { for (let x = xStart; x <= xEnd; x++) { drawn.push({x, y, color: state.color}); } } dispatch({picture: state.picture.draw(drawn)}); } drawRectangle(start); return drawRectangle; }
هناك تفصيل مهم في هذا التنفيذ، وهو أنك حين تسحب المؤشر سيعاد رسم المستطيل على الصورة من الحالة الأصلية، وهكذا تستطيع جعل المستطيل أكبر أو أصغر مرةً أخرى أثناء إنشائه دون مستطيل وسيط يتبقى في الصورة النهائية، وهذا أحد الأسباب التي تجعل كائنات الصورة غير القابلة للتغيّر مفيدةً، كما سننظر في سبب آخر لاحقًا، وسيكون تنفيذ مهمة ملء اللون أكثر تفصيلًا، إذ هي أداة تملأ البكسل الذي تحت المؤشر والبكسلات المجاورة له التي لها اللون نفسه، وإنما نعني بالمجاورة له تلك البكسلات المجاورة رأسيًا أو عموديًا مباشرةً وليس البكسلات المجاورة قطريًا له، كما توضِّح الصورة التالية مجموعة بكسلات تُلوَّن باستخدام أداة الملء على البكسل الذي يحمل النقطة الحمراء.
من المثير أن الطريقة التي نفعل بها ذلك تشبه شيفرة الاستكشاف التي تعرضنا لها في مقال مشروع تطبيقي لبناء رجل آلي (روبوت) عبر جافاسكريبت، حيث بحثت تلك الشيفرة في مخطط لإيجاد طريق ما للروبوت، وتبحث هذه الشيفرة في شبكة لإيجاد كل البكسلات المرتبطة ببعضها بعضًا، لكن مشكلة تتبع مجموعة الفروع الممكنة مشابهةً هنا.
const around = [{dx: -1, dy: 0}, {dx: 1, dy: 0}, {dx: 0, dy: -1}, {dx: 0, dy: 1}]; function fill({x, y}, state, dispatch) { let targetColor = state.picture.pixel(x, y); let drawn = [{x, y, color: state.color}]; for (let done = 0; done < drawn.length; done++) { for (let {dx, dy} of around) { let x = drawn[done].x + dx, y = drawn[done].y + dy; if (x >= 0 && x < state.picture.width && y >= 0 && y < state.picture.height && state.picture.pixel(x, y) == targetColor && !drawn.some(p => p.x == x && p.y == y)) { drawn.push({x, y, color: state.color}); } } } dispatch({picture: state.picture.draw(drawn)}); }
تتصرف مصفوفة البكسلات المرسومة على أساس قائمة العمل للدالة، فيجب علينا من أجل كل بكسل نصل إليه رؤية إذا كان أيّ بكسل مجاور له يحمل اللون نفسه ولم يُدهن مسبقًا، وتتأخر حلقة العد التكرارية خلف طول مصفوفة drawn
بسبب إضافة البكسلات الجديدة، كما سيحتاج أيّ بكسل يسبقها إلى استكشافه، وحين تلحق بالطول فستكون كل البكسلات قد استُكشِفت وقد أتمت الدالة عملها؛ أما الأداة النهائية فهي مختار الألوان color picker الذي يسمح لك بالإشارة إلى لون في الصورة لاستخدامه على أساس لون الرسم الحالي.
function pick(pos, state, dispatch) { dispatch({color: state.picture.pixel(pos.x, pos.y)}); }
نستطيع الآن اختبار التطبيق.
<div></div> <script> let state = { tool: "draw", color: "#000000", picture: Picture.empty(60, 30, "#f0f0f0") }; let app = new PixelEditor(state, { tools: {draw, fill, rectangle, pick}, controls: [ToolSelect, ColorSelect], dispatch(action) { state = updateState(state, action); app.syncState(state); } }); document.querySelector("div").appendChild(app.dom); </script>
الحفظ والتحميل
لا شك أننا حين نرسم الصورة الخاصة بنا سنود حفظها لاحقًا، كما يجب إضافة زر لتحميل الصورة الحالية على أساس ملف صورة، حيث يوفر المتحكم التالي هذا الزر:
class SaveButton { constructor(state) { this.picture = state.picture; this.dom = elt("button", { onclick: () => this.save() }, "? Save"); } save() { let canvas = elt("canvas"); drawPicture(this.picture, canvas, 1); let link = elt("a", { href: canvas.toDataURL(), download: "pixelart.png" }); document.body.appendChild(link); link.click(); link.remove(); } syncState(state) { this.picture = state.picture; } }
يتتبّع المكون الصورة الحالية ليستطيع الوصول إليها عند الحفظ، كما يستخدِم عنصر <canvas>
لإنشاء ملف الصورة والذي يرسم الصورة على مقياس بكسل واحد لكل بكسل، في حين ينشئ التابع toDataURL
الذي على عنصر اللوحة رابطًا يبدأ بـ data:
على عكس الروابط التي تبدأ ببروتوكولات http:
وhttps:
العادية، تحتوي هذه الروابط على المصدر كاملًا في الرابط، كما تكون طويلةً جدًا لهذا، لكنه يسمح لنا بإنشاء روابط عاملة إلى صور عشوائية من داخل المتصفح.
ننشئ عنصر رابط للوصول إلى المتصفح وتحميل الصورة يشير إلى هذا الرابط وله سمة download
، وعندما يُنقَر على مثل هذه الروابط فستجعل المتصفح يعرض صندوقًا حواريًا لحفظ الملف، كما نضيف ذلك الرابط إلى المستند ونحاكي النقر عليه ثم نحذفه مرةً أخرى، وهكذا تستطيع فعل الكثير والكثير باستخدام التقنيات المتاحة في المتصفح لكن قد تبدو بعض هذه التقنيات غريبةً، بل إذا أردنا أن نكون قادرين على تحميل ملفات صورة موجودة إلى تطبيقنا، فسنحتاج إلى تعريف مكوِّن لزر، أي كما في المثال التالي:
class LoadButton { constructor(_, {dispatch}) { this.dom = elt("button", { onclick: () => startLoad(dispatch) }, "? Load"); } syncState() {} } function startLoad(dispatch) { let input = elt("input", { type: "file", onchange: () => finishLoad(input.files[0], dispatch) }); document.body.appendChild(input); input.click(); input.remove(); }
إذا أردنا الوصول إلى ملف في حاسوب المستخدِم، فسيكون على المستخدِم اختيار الملف من حقل إدخال الملف، لكنّا لا نريد أن يبدو زر التحميل مثل حقل إدخال ملف، لذا سننشئ إدخال الملف عندما يُنقر على الزر ونتظاهر حينها أنّ إدخال الملف ذاك قد نُقر عليه، فإذا اختار المستخدِم ملفًا، فسنستطيع استخدام FileReader
للوصول إلى محتوياته في صورة رابط بيانات كما ذكرنا قبل قليل، ويمكن استخدام هذا الرابط لإنشاء عنصر <img>
، لكن بسبب أننا لا نستطيع الوصول مباشرةً إلى البكسلات في مثل هذه الصورة فلا نستطيع إنشاء كائن Picture
منها.
function finishLoad(file, dispatch) { if (file == null) return; let reader = new FileReader(); reader.addEventListener("load", () => { let image = elt("img", { onload: () => dispatch({ picture: pictureFromImage(image) }), src: reader.result }); }); reader.readAsDataURL(file); }
يجب علينا رسم الصورة أولًا في عنصر <canvas>
كي نصل إلى البكسلات، كما يملك سياق اللوحة canvas التابع getImageData
الذي يسمح للسكربت قراءة بكسلاتها، لذا بمجرد أن تكون الصورة على اللوحة يمكننا الوصول إليها وبناء كائن Picture
.
function pictureFromImage(image) { let width = Math.min(100, image.width); let height = Math.min(100, image.height); let canvas = elt("canvas", {width, height}); let cx = canvas.getContext("2d"); cx.drawImage(image, 0, 0); let pixels = []; let {data} = cx.getImageData(0, 0, width, height); function hex(n) { return n.toString(16).padStart(2, "0"); } for (let i = 0; i < data.length; i += 4) { let [r, g, b] = data.slice(i, i + 3); pixels.push("#" + hex(r) + hex(g) + hex(b)); } return new Picture(width, height, pixels); }
سنحدّ من حجم الصور إلى أن تكون 100 * 100 بكسل، بما أن أي شيء أكبر من هذا سيكون أكبر من أن يُعرض على الشاشة وقد يبطئ الواجهة، كما تكون خاصية data
الخاصة بالكائن الذي يعيده getImageData
مصفوفةً من مكونات الألوان، إذ تحتوي على أربع قيم لكل بكسل في المستطيل الذي تحدده الوسائط، حيث تمثل مكونات البكسل اللونية من الأحمر والأخضر والأزرق والشفافية alpha، كما تكون هذه المكونات أرقامًا تتراوح بين الصفر و255، ويعني الصفر في خانة الألفا أنه شفاف تمامًا و255 أنه مصمت، لكن سنتجاهل هذا في مثالنا إذ لا يهمنا كثيرًا.
يتوافق كل رقمين ست-عشريين لكل مكوِّن مستخدَم في ترميزنا للألوان توافقًا دقيقًا للمجال الذي يتراوح بين الصفر و255، حيث يستطيع هذان الرقمان التعبير عن 162 = 256 عددًا، كما يمكن إعطاء القاعدة إلى التابع toString
الخاص بالأعداد على أساس وسيط كي ينتج n.toString(16)
تمثيلًا من سلسلة نصية في النظام الست عشري، ويجب التأكد من أنّ كل عدد يأخذ رقمين فقط، لذلك فإن الدالة المساعِدة hex
تستدعي padStart
لإضافة صفر بادئ عند الحاجة، ونستطيع الآن التحميل والحفظ ولم يبق إلا ميزة إضافية واحدة.
سجل التغييرات Undo History
ستكون نصف عملية التعديل على الصور بارتكاب أخطاء صغيرة بين الحين والآخر ثم تصحيحها، لذا من المهم لنا وجود سجل للخطوات التي ينفذها المستخدِم كي يستطيع العودة إليها وتصحيح ما يريده، حيث سنحتاج لهذا تخزين النسخ السابقة من الصورة، وهو أمر يسير بما أنها قيمة غير قابلة للتغيّر لكنها تحتاج حقلًا إضافيًا داخل حالة التطبيق.
سنضيف مصفوفة done
للحفاظ على النسخ السابقة من الصورة، كما سيتطلب الحفاظ على هذه الخاصية دالةً معقدةً لتحديث الحالة بحيث تضيف الصورة إلى المصفوفة، لكن لا نريد تخزين كل تغيير يحدث، وإنما التغييرات التي تحدث كل فترة زمنية محدَّدة، لذا سنحتاج إلى خاصية ثانية هي doneAt
تتتبّع آخر وقت حفظنا فيه صورة في السجل.
function historyUpdateState(state, action) { if (action.undo == true) { if (state.done.length == 0) return state; return Object.assign({}, state, { picture: state.done[0], done: state.done.slice(1), doneAt: 0 }); } else if (action.picture && state.doneAt < Date.now() - 1000) { return Object.assign({}, state, action, { done: [state.picture, ...state.done], doneAt: Date.now() }); } else { return Object.assign({}, state, action); } }
إذا كان الإجراء هو إجراء تراجع undo، فستأخذ الدالة آخر صورة من السجل وتجعلها هي الصورة الحالية، حيث تضبط doneِAt
على صفر كي نضمن أن التغيير التالي يخزِّن الصورة في السجل مما يسمح لك بالعودة إليها في وقت آخر إذا أردت؛ أما إن كان الإجراء غير ذلك ويحتوي على صورة جديدة وكان آخر وقت تخزين صورة أكثر من ثانية واحدة -أي أكثر من 1000 ميلي ثانية-، فستُحدَّث خصائص done
وdoneAt
لتخزين الصورة السابقة، كما لا يفعل مكوّن زر التراجع الكثير، إذ يرسِل إجراءات التراجع عند النقر عليه ويعطِّل نفسه إذا لم يكن ثمة شيء يُتراجع عنه.
class UndoButton { constructor(state, {dispatch}) { this.dom = elt("button", { onclick: () => dispatch({undo: true}), disabled: state.done.length == 0 }, "⮪ Undo"); } syncState(state) { this.dom.disabled = state.done.length == 0; } }
لنرسم
نحتاج أولًا إلى إنشاء حالة كي نستطيع استخدام التطبيق مع مجموعة من الأدوات والمتحكمات ودالة إرسال، ثم نمرر إليها باني PixelEditor
لإنشاء المكوِّن الأساسي، وبما أننا نحتاج إلى إنشاء عدة محررات في التدريبات، فسنعرِّف بعض الرابطات bindings أولًا.
const startState = { tool: "draw", color: "#000000", picture: Picture.empty(60, 30, "#f0f0f0"), done: [], doneAt: 0 }; const baseTools = {draw, fill, rectangle, pick}; const baseControls = [ ToolSelect, ColorSelect, SaveButton, LoadButton, UndoButton ]; function startPixelEditor({state = startState, tools = baseTools, controls = baseControls}) { let app = new PixelEditor(state, { tools, controls, dispatch(action) { state = historyUpdateState(state, action); app.syncState(state); } }); return app.dom; }
تستطيع استخدام =
بعد اسم الرابطة حين نفكك كائنًا أو مصفوفةً كي تعطي الرابطة قيمةً افتراضيةً تُستخدَم عندما تكون الخاصية مفقودةً أو تحمل قيمة غير معرفة undefined
، كما تستفيد دالة StartPixelEditor
من هذا في قبول كائن له عدد من الخصائص الاختيارية على أساس وسيط، فإذا لم توفر خاصية tools
، فستكون مقيدةً إلى baseTools
، وتوضِّح الشيفرة التالية كيفية الحصول على محرر حقيقي على الشاشة:
<div></div> <script> document.querySelector("div") .appendChild(startPixelEditor({})); </script>
تستطيع الآن الرسم فيه إذا شئت.
سبب صعوبة البرنامج
لا شك أنّ التقنيات المتعلقة بالمتصفحات رائعة وتمكننا من فعل الكثير بالواجهات المرئية من بنائها وتعديلها بل وتنقيحها من الأخطاء البرمجية كذلك، كما ستضمن أنّ البرنامج الذي تكتبه من أجل المتصفح سيعمل على كل حاسوب وهاتف يتصل بالإنترنت، لكن تُعَدّ تقنيات المتصفحات هذه بحارًا واسعةً، إذ عليك تعلّم الكثير الكثير من الطرق والأدوات لتستطيع الاستفادة منها، كما أنّ النماذج البرمجية الافتراضية المتاحة لها كثيرة المشاكل إلى حد أن أغلب المبرمجين يفضلون التعامل مع طبقات مجرَّدة عليها عوضًا عن التعامل المباشر معها، وعلى الرغم من اتجاه الوضع نحو الأفضل، إلا أن هذا يكون في صورة إضافة مزيد من العناصر لحل المشاكل وأوجه القصور الموجودة مما يخلق المزيد من التعقيد.
لا يمكن استبدال الميزة المستخدَمة من قِبل ملايين المواقع بقرار واحد بسهولة، بل حتى لو أمكن ذلك فمن الصعب الاتفاق على بديلها، ونحن مقيدون بأدواتنا والعوامل الاجتماعية والاقتصادية والتاريخية التي أثرت في إنشائها، والأمر الذي قد يفيدنا في هذا هو أننا قد نصبح أفضل في الإنتاجية إذا عرفنا كيف تعمل هذه التقنيات ولماذا هي على الوجه التي عليه بدلًا من الثورة عليها وتجنبها كليًا.
قد تكون التجريدات الجديدة مفيدةً حقًا، فقد كان نموذج المكونات وأسلوب تدفق البيانات اللذان استخدمناهما في هذا المقال مثالًا على ذلك، وهناك الكثير من المكتبات التي تجعل برمجة واجهة المستخدِم أفضل وأسهل، سيما React و Angular الشائعتا الاستخدام وقت كتابة هذه السلسلة بنسختها الأصلية، لكن هذا مجال كبير بحد ذاته ننصحك بإنفاق بعض الوقت في تصفِّحه كي تعرف كيف تعمل هذه المكتبات والفوائد التي تجنيها منها.
تدريبات
لا زال هناك مساحة نطور فيها برنامجنا، فلمَ لا نضيف بعض المزايا الجديدة في صورة تدريبات؟
رابطات لوحة المفاتيح
أضف اختصارات للوحة المفاتيح إلى التطبيق، بحيث إذا ضغطنا على الحرف الأول من اسم أداة فستُختار الأداة، وctrl+z يفعِّل إجراء التراجع.
افعل ذلك عبر تعديل مكوِّن PixelEditor
وأضف خاصية tabIndex
التي تساوي 0 إلى العنصر المغلِّف <div>
كي يستطيع استقبال التركيز من لوحة المفاتيح.
لاحظ أن الخاصية الموافقة لسمة tabindex
تُسمى tabIndex
حيث يكون حرف I
فيها على الصورة الكبيرة، كما تتوقع دالة elt
أسماء خصائص، ثم سجِّل معالجات أحداث المفاتيح مباشرةً على ذلك العنصر، حيث سيعني هذا أنه عليك ضغط أو لمس أو نقر التطبيق قبل أن تستطيع التفاعل معه بلوحة المفاتيح.
تذكَّر أنّ أحداث لوحة المفاتيح لها الخاصيتان ctrlKey
وmetaKey
-المخصص لزر command في ماك-، حيث تستطيع استخدامهما لتعرف هل هذان الزران مضغوط عليهما أم لا.
تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen.
<div></div> <script> // الأصلي PixelEditor صنف. // وسع المنشئ. class PixelEditor { constructor(state, config) { let {tools, controls, dispatch} = config; this.state = state; this.canvas = new PictureCanvas(state.picture, pos => { let tool = tools[this.state.tool]; let onMove = tool(pos, this.state, dispatch); if (onMove) { return pos => onMove(pos, this.state, dispatch); } }); this.controls = controls.map( Control => new Control(state, config)); this.dom = elt("div", {}, this.canvas.dom, elt("br"), ...this.controls.reduce( (a, c) => a.concat(" ", c.dom), [])); } syncState(state) { this.state = state; this.canvas.syncState(state.picture); for (let ctrl of this.controls) ctrl.syncState(state); } } document.querySelector("div") .appendChild(startPixelEditor({})); </script>
إرشادات للحل
-
ستكون خاصية
key
لأحداث مفاتيح الأحرف هي الحرف نفسه في حالته الصغرى إذا لم يكن زر shift مضغوطًا، لكن لا تهمنا أحداث المفاتيح التي فيها زر shift. -
يستطيع معالج
"keydown"
فحص كائن الحدث الخاص به ليرى إذا كان يطابق اختصارًا من الاختصارات، كما تستطيع الحصول على قائمة من الأحرف الأولى من كائنtools
كي لا تضطر إلى كتابتها. -
إذا طابق حدث مفتاح اختصارًا ما، استدع
preventDefault
عليه وأرسل الإجراء المناسب.
الرسم بكفاءة
سيكون أغلب العمل الذي يفعله التطبيق أثناء الرسم داخل drawPicture
، ورغم أنّ إنشاء حالة جديدة وتحديث بقية DOM لن يكلفنا كثيرًا، إلا أنّ إعادة رسم جميع البكسلات في اللوحة يمثِّل مهمةً ثقيلةً، لذا جِد طريقةً لتسريع تابع syncState
الخاص بـ PictureCanvas
عبر إعادة الرسم في حالة تغير البكسلات فقط.
تذكَّر أنّ drawPicture
تُستخدَم أيضًا بواسطة زر الحفظ، فتأكد إذا غيرتها من أنك لا تخرِّب الوظيفة القديمة، أو أنشئ نسخةً جديدةً باسم مختلف، ولاحظ أنّ تغيير حجم عنصر <canvas>
عبر ضبط خاصيتَي width
وheight
له يتسبب في مسحه ليصير شفافًا مرةً أخرى.
تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen.
<div></div> <script> // غيّر هذا التابع PictureCanvas.prototype.syncState = function(picture) { if (this.picture == picture) return; this.picture = picture; drawPicture(this.picture, this.dom, scale); }; // ربما تود تغيير هذا أو استخدامه كذلك. function drawPicture(picture, canvas, scale) { canvas.width = picture.width * scale; canvas.height = picture.height * scale; let cx = canvas.getContext("2d"); for (let y = 0; y < picture.height; y++) { for (let x = 0; x < picture.width; x++) { cx.fillStyle = picture.pixel(x, y); cx.fillRect(x * scale, y * scale, scale, scale); } } } document.querySelector("div") .appendChild(startPixelEditor({})); </script>
إرشادات للحل
يمثِّل هذا التدريب مثالًا ممتازًا لرؤية كيف تسرِّع هياكل البيانات غير القابلة للتغير من الشيفرة، كما نستطيع الموازنة بين الصورة القديمة والجديدة بما أن لدينا كليهما وإعادة الرسم في حالة تغير لون البكسلات فقط، مما يوفر 99% من مهمة الرسم في الغالب.
اكتب دالة updatePicture
جديدةً أو اجعل دالة drawPicture
تأخذ وسيطًا إضافيًا قد يكون غير معرَّف أو قد يكون الصورة السابقة، وتتحقق الدالة لكل بكسل مما إذا كانت الصورة السابقة قد مرت على هذا الموضع باللون نفسه أم لا، كما تتخطى البكسل الذي تكون تلك حالته.
يجب عليك تجنب width
وheight
حين يكون للصورتين الجديدة والقديمة نفس الحجم لأن اللوحة تُمسح حين يتغير حجمها، فإذا اختلفتا -وتلك ستكون حالتنا إذا حُمِّلت صورة جديدة- فيمكنك ضبط الرابطة التي تحمل الصورة القديمة على null بعد تغيير حجم اللوحة، إذ يجب ألا تتخطى أيّ بكسل بعد تغيير حجم اللوحة.
الدوائر
عرِّف أداةً اسمها circle
ترسم دائرةً مصمتةً حين تسحب بالمؤشر، حيث يكون مركز الدائرة عند نقطة بداية السحب، ويُحدَّد نصف قطره بالمسافة المسحوبة.
تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen.
<div></div> <script> function circle(pos, state, dispatch) { // ضع شيفرتك هنا } let dom = startPixelEditor({ tools: Object.assign({}, baseTools, {circle}) }); document.querySelector("div").appendChild(dom); </script>
إرشادات للحل
تستطيع النظر في أداة rectangle
لتستقي منها إرشادًا لهذا التدريب، حيث ستحتاج إلى إبقاء الرسم على صورة البدء بدلًا من الصورة الحالية عندما يتحرك المؤشر، ولكي تعرف أيّ البكسلات يجب تلوينها، استخدام نظرية فيثاغورس بحساب المسافة بين الموضع الحالي للمؤشر والموضع الابتدائي من خلال أخذ الجذر التربيعي Math.sqrt
لمجموع تربيع Math.pow(x, 2)
لفرق في إحداثيات x وتربيع الفرق في إحداثيات y.
كرر بعد ذلك على تربيع البكسلات حول نقطة البداية التي تكون جوانبها ضعف نصف القطر على الأقل، ولوّن تلك التي تكون داخل نصف قطر الدائرة باستخدام معادلة فيثاغورس مرةً أخرى لتعرف بُعدها عن المركز، وتأكد من أنك لا تلون البكسلات التي تكون خارج حدود الصورة.
الخطوط المستقيمة
يُعَدّ هذا التدريب متقدمًا أكثر مما قبله وسيحتاج إلى تصميم حل لمشكلة ليست بالهينة، لذا تأكد من امتلاكك وقت وصبر قبل أن تبدأ العمل عليه، ولا يمنعنك الفشل في المحاولات أن تعيد الكرة.
عندما تختار أداة draw
في أغلب المتصفحات وتسحب المؤشر بسرعة ستجد أن ما حصلت عليه خطًا من النقاط التي تفصل بينها مسافات فارغة، وذلك لأن حدثَي "mousemove"
أو "touchmove"
لا ينطلقان بسرعة تغطي كل بكسل تمر عليه، لذا نريد منك تطوير أداة draw
لتجعلها ترسم خطًا كاملًا، وهذا يعني أنه عليك جعل دالة معالج الحركة تتذكر الموضع السابق وتصله بالموضع الحالي، ولكي تفعل ذلك عليك كتابة دالة رسم خط عامة بما أنّ البكسلات التي تمر عليها قد لا تكون متجاورةً بما يصلح لخط مستقيم.
يُعَدّ الخط المستقيم بين بكسلين سلسلةً من البكسلات المتصلة في سلسلة واحدة بأقرب هيئة تمثل خطًا مستقيمًا من البداية إلى النهاية، ويُنظر إلى البكسلات المتجاورة قطريًا على أنها متصلة، لذا فإن الخط المائل يجب أن يبدو مثل الصورة التي على اليسار وليس الصورة اليمنى.
أخيرًا، إذا كانت لدينا شيفرةً ترسم خطًا بين نقطتين عشوائيتين فربما تريد استخدامها كي تعرِّف أداة سطر line
ترسم خطًا مستقيمًا بين بداية السحب ونهايته.
تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen.
<div></div> <script> // هذه أداة الرسم القديمة، أعد كتابتها. function draw(pos, state, dispatch) { function drawPixel({x, y}, state) { let drawn = {x, y, color: state.color}; dispatch({picture: state.picture.draw([drawn])}); } drawPixel(pos, state); return drawPixel; } function line(pos, state, dispatch) { // ضع شيفرتك هنا. } let dom = startPixelEditor({ tools: {draw, line, fill, rectangle, pick} }); document.querySelector("div").appendChild(dom); </script>
إرشادات للحل
تتكون مشكلة رسم خط من البكسلات من أربع مشكلات تختلف اختلافًا طفيفًا فيما بينها، إذ يُعَدّ رسم خط أفقي من اليسار إلى اليمين سهلًا إذ تكرر على إحداثيات x وتلون البكسل في كل خطوة، فإذا كان الخط يميل قليلًا أقل من 45 درجة أو ¼π
راديان، فتستطيع وضع إحداثيات y مع الميل، لكن لا زلت في حاجة إلى بكسل لكل موضع x، ويُحدِّد الميل موضع y لكل بكسل من هذه البكسلات.
لكن ستحتاج إلى تغيير الطريقة التي تعامل بها الإحداثيات بمجرد تجاوز الميل درجة 45، حيث ستحتاج الآن إلى بكسل واحد لكل موضع y بما أن الخط يتقدم إلى الأعلى أكثر من سيره إلى اليسار، وعندما تتجاوز 135 درجة فعليك العودة إلى التكرار على إحداثيات x لكن من اليمين إلى اليسار.
لست بحاجة إلى كتابة أربع حلقات تكرارية، وبما أنّ رسم خط من A إلى B هو نفسه رسم خط من B إلى A، فيمكنك نبديل مواضع البداية والنهاية للخطوط التي تبدأ من اليمين إلى اليسار وتعامِلها على أنها من اليسار إلى اليمين، لذا تحتاج إلى حلقتين تكراريتين مختلفتين، وأول شيء يجب أن تفعله دالة رسم الخطوط هو معرفة هل الفرق بين إحداثيات x أكبر من الفرق بين إحداثيات y أم لا، فإذا كان فإنّ هذا خط مائل للأفقية، وإلا فإنه يميل لأن يكون رأسيًا.
تأكد من أن توازن بين القيم المطلقة لفروق x وy والتي تحصل عليها بواسطة Math.abs
، وبمجرد معرفتك أيّ محور ستكرر عليه، تستطيع تفقد نقطة البدء لترى إذا كان لها إحداثي أعلى على هذا المحور من نقطة النهاية أم لا وتبدلهما إذا دعت الحاجة، وتكون الطريقة المختصرة هنا لتبديل قيم رابطتين في جافاسكربت تستخدِم مهمة تفكيك كما يلي:
[start, end] = [end, start];
ثم تحسب ميل الخط الذي يُحدِّد المقدار الذي يتغير به الإحداثي على المحور الآخر لكل خطوة تأخذها على المحور الأساسي، وتستطيع تشغيل حلقة تكرارية هنا على المحور الأساسي أثناء تتبع الموضع الموافق على المحور الآخر، كما يمكنك رسم بكسلات على كل تكرار، لكن تأكد من أن تقرِّب إحداثيات المحور غير الأساسي بما أنها ستكون أعدادًا كسرية على الأرجح ولا يتجاوب معها تابع draw
جيدًا.
ترجمة -بتصرف- للفصل التاسع عشر من كتاب Elequent Javascript لصاحبه Marijn Haverbeke.
أفضل التعليقات
لا توجد أية تعليقات بعد
انضم إلى النقاش
يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.