الدونت · فصل ٥

الدونت — رسم بإيدك + spring + stagger

Render(DrawingContext) · RequestAnimationFrame · Spring · stiffness · damping · substeps · stagger
🎯 الهدف: تكتب spring بإيدك، وتشغّلها في loop بـ requestAnimationFrame، وتعرف ليه Filee بيقسّم الفريم لخطوات صغيرة.

ده أحلى جزء في Filee. لما تدوس Ctrl وتسحب ملف، بتطلع دايرة شرايح عند الماوس. الشرايح بتطلع ورا بعض بنطة، واللي تحت الماوس بتطلع لبرا وتنوّر.

ده مش زراير. ده canvas. جرّبه هنا، ده نفس الكود بالأرقام بتاعته بس متحوّل لـ JavaScript:

دونت Filee على canvasعدّي على الشرايح، ودوس على شريحة أو على النص

١. بيرسم بإيده

DonutMenu هو control بيعمل override لـ Render. مفيش أي عنصر جواه. كل شريحة هي شكل (wedge) بيترسم بـ DrawGeometry، وقبله PushTransform للتكبير واللف، و PushOpacity للشفافية.

Controls/DonutMenu.cs (Filee، مختصر)
public override void Render(DrawingContext context){    for (var i = 0; i < n; i++)    {        var open = _open[i];            // من 0 لـ 1 (وأكتر شوية وقت النطة)        var hover = _hover[i].Value;    // spring: 0 أو 1        // الشريحة بتكبر من النص مع لفة صغيرة        var scale = 0.55 + 0.45 * open;        var rotate = (1 - Math.Min(open, 1)) * -0.35;        // واللي تحت الماوس بتطلع لبرا        var offset = DonutGeometry.PointAt(new Point(0, 0), hover * PopOut, mid);        using (context.PushTransform(/* scale + rotate + offset */))        using (context.PushOpacity(Math.Clamp(open, 0, 1)))        {            context.DrawGeometry(/* لون الشريحة */, /* الإطار */, CreateWedge(...));        }    }}

على الويب نفس الكلام بالظبط بـ ctx.save() و ctx.translate() و ctx.rotate() و ctx.scale() و ctx.globalAlpha، وبعدين ترسم الشريحة بقوسين ctx.arc().

٢. الـ loop: فريم بيطلب اللي بعده

مفيش timer. كل فريم بيحسب الوقت اللي فات من الفريم اللي قبله (dt)، يحدّث القيم، يرسم، ولو لسه في حاجة بتتحرك يطلب فريم كمان. لما كله يهدى، الـ loop بيقف لوحده، فالأبلكيشن مش بياكل CPU وهو واقف.

donut-loop.ts
let last: number | null = null;let raf = 0;function frame(now: number) {  // الوقت الحقيقي بين الفريمين. Math.min عشان لو التاب كان مستخبي منقفزش ثانية كاملة  const dt = last === null ? 1 / 60 : Math.min(0.1, (now - last) / 1000);  last = now;  const busy = stepAll(dt); // حدّث كل الـ springs والـ open  draw();                   // ارسم  if (busy) raf = requestAnimationFrame(frame);  else { raf = 0; last = null; } // كله هدي: وقّف}// أي حاجة بتتغير (ماوس، فتح، قفل) بتصحّي الـ loop لو كان نايمfunction kick() {  if (!raf) raf = requestAnimationFrame(frame);}

٣. الـ spring: قوة + تقل

ده الكود كله. ١٠ سطور:

Filee (C#)
private struct Spring{    public double Value;    public double Velocity;    private const double MaxStep = 1 / 120.0;    public bool Step(double target, double dt,        double stiffness = 320, double damping = 20)    {        var steps = Math.Max(1,            (int)Math.Ceiling(dt / MaxStep));        var h = dt / steps;        for (var i = 0; i < steps; i++)        {            var force = stiffness * (target - Value)                      - damping * Velocity;            Velocity += force * h;            Value += Velocity * h;        }        var settled = Math.Abs(target - Value) < 0.001                   && Math.Abs(Velocity) < 0.01;        if (settled) { Value = target; Velocity = 0; }        return !settled;    }}
الويب (TypeScript)
class Spring {  value = 0;  velocity = 0;  static maxStep = 1 / 120;  step(target: number, dt: number,       stiffness = 320, damping = 20) {    const steps = Math.max(1,      Math.ceil(dt / Spring.maxStep));    const h = dt / steps;    for (let i = 0; i < steps; i++) {      const force = stiffness * (target - this.value)                  - damping * this.velocity;      this.velocity += force * h;      this.value += this.velocity * h;    }    const settled = Math.abs(target - this.value) < 0.001                 && Math.abs(this.velocity) < 0.01;    if (settled) { this.value = target; this.velocity = 0; }    return !settled; // true = لسه بيتحرك  }}

جرّب الأرقام بنفسك. دوس في أي حتة على الحارة عشان تغيّر الهدف، حتى والكورة لسه بتتحرك. هتلاقيها بتلف وتكمل من سرعتها، مش بتبدأ من الصفر. ده الفرق الكبير بين الـ spring والـ easing.

ملعب الـ springدوس في أي حتة على الحارة، أو غيّر الأرقام

critical damping: نطة وانت داخل، لأ وانت خارج

الرقم السحري: damping = 2 × √stiffness. ده اسمه critical damping: أسرع وصول للهدف من غير نطة. مع stiffness = 320 يبقى حوالي 35.8، و Filee كاتبه مقرّب: const double CriticalDamping = 36.

Filee بيستخدم ده بذكاء في الـ hover:

// الشريحة اللي دخلت تحت الماوس: تنط (320 / 20)// الشريحة اللي سبتها: ترجع من غير نطة (critical)active |= i == ActiveIndex    ? _hover[i].Step(1, dt)    : _hover[i].Step(0, dt, 320, CriticalDamping);

وكاتب السبب في تعليق: لو الشريحة نطت تحت الصفر وهي راجعة، اللون هينوّر تاني لحظة. يعني هتحس إنها رمشت.

ليه الخطوات الصغيرة (substeps)؟

ده التعليق اللي في كود Filee حرفيًا:

With these stiffnesses a single step of a late frame (50 ms) overshoots more each frame instead of settling: slices stayed lit and flew apart on a busy machine.

يعني: على جهاز تقيل، لو فريم اتأخر ٥٠ms وحسبته في خطوة واحدة، الـ spring بتعدّي الهدف أكتر كل مرة وبتفرقع. الشرايح كانت بتطير.

الحل: قسّم الـ dt لخطوات أقصاها 1/120 ثانية. فريم ٥٠ms يبقى ٦ خطوات صغيرة، والحساب يفضل مستقر.

٤. الـ stagger: شريحة ورا شريحة

الفتح مش spring. هو easing بوقت، عشان كل شريحة تبدأ بعد اللي قبلها بـ ٢٨ms:

Controls/DonutMenu.cs (Filee)
private const double OpenDuration = 0.42;private const double OpenStagger = 0.028;// كل فريم:_openClock += dt;for (var i = 0; i < _open.Length; i++){    var t = Math.Clamp(        (_openClock - i * (_closing ? 0.01 : OpenStagger))        / (_closing ? 0.16 : OpenDuration), 0, 1);    _open[i] = _closing ? 1 - EaseInCubic(t) : EaseOutBack(t);}
الفتح
  • كل شريحة متأخرة 28ms عن اللي قبلها
  • كل شريحة بتاخد 420ms
  • EaseOutBack: بتنط
القفل
  • التأخير 10ms بس (تقريبًا مع بعض)
  • كل شريحة بتاخد 160ms
  • EaseInCubic: من غير نطة

٥. الضربة في النص

لما تختار صيغة، النص بينبض نبضة صغيرة. ده spring بقيمة 0 دايمًا، بس بنديله سرعة فجأة، فيطلع ويرجع لوحده:

pulse.velocity = 9;                 // ضربة// كل فريم:pulse.step(0, dt, 260, 12);         // damping قليل = يهتز شويةconst r = baseRadius * (1 + pulse.value * 0.08);

حيلة حلوة لأي “نبضة”: زرار اتعمله save، badge عليها رقم جديد، أيكونة اتضافت للسلة.

خلّصت الفصل لما

🧠 اختبر نفسك
١) إيه الفرق بين spring و easing؟
الـ easing ليه مدة ثابتة ومنحنى ثابت. الـ spring ليها قوة وسرعة، فلو الهدف اتغير في النص بتكمل من سرعتها الحالية بنعومة.
٢) critical damping بكام لو stiffness = 320؟
2 × √320 ≈ 35.8.
٣) ليه Filee بيقسّم dt لخطوات أقصاها 1/120؟
لأن فريم متأخر (50ms) في خطوة واحدة بيخلي الـ spring القوية تعدّي أكتر كل مرة وتفرقع. خطوات صغيرة = حساب مستقر.
٤) الـ loop بيقف إمتى؟
لما كل الـ springs والـ open يوصلوا لهدفهم (Step بترجع false). وأي تغيير بعدها بيصحّيه تاني.