معماری بک‌اند

یک کره‌ی چرخان WebGL برای هیروی سایت Next.js — نقطه‌ها از دیتابیس، کمان‌ها روی سطح و چهار محافظ

کره‌ای با cobe که قاره‌هایش از نقطه ساخته شده، روی محل هر رله نشانگری دارد و میان آن‌ها کمان نور می‌کشد؛ همه از ردیف‌های واقعی دیتابیس. ساختش در Next.js، مسیر داده تا نشانگرها و چهار محافظی که هر کدام برای یک خرابی مشخص اضافه شد.

Next.js, React and WebGL

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

این کره دو نسخه دارد: کامپوننت کلاینتی React در سایت Next.js، با tooltip و چرخاندن با کشیدن، و ماژول ES ساده و صرفاً تزئینی در صفحه‌ای از همان سرویس که روی سرور رندر می‌شود. چهار محافظ در راهنمای ساخت نسخه‌ی دوم ثبت شده‌اند؛ هر جا نسخه‌ی React فرق دارد، می‌گویم.

چرا cobe

cobe با مجوز MIT و حدود 7 کیلوبایت، همان چیزی را می‌کشد که کره‌ی هیرو لازم دارد: نقشه‌ی نقطه‌ای جهان، هاله‌ی جو، چرخش و نشانگر. تنها وابستگی‌اش، phenomenon، حدود 6 کیلوبایت است. نسخه‌ی تزئینی هر دو را vendor کرده، چون آن پروژه آفلاین و پشت mirror ساخته می‌شود. cobe مفهومی از اتصال دو نقطه ندارد؛ پس کمان‌ها روی یک canvas دوبعدی جداگانه کشیده می‌شوند که روی کره قرار گرفته و هر فریم از نو رسم می‌شود.

کامپوننت در Next.js

فایل با "use client" شروع می‌شود و پرچم isClient تا لحظه‌ی mount فقط یک placeholder نشان می‌دهد. بعد یک effect کره را می‌سازد:

try {
    globeInstanceRef.current = createGlobe(canvasRef.current, {
        ...GLOBE_CONFIG,
        width: widthRef.current * 2,
        height: widthRef.current * 2,
        onRender,
    });
} catch (error) {
    console.warn("The globe needs WebGL, which this browser does not offer:", error);
    window.removeEventListener("resize", onResize);
    onUnavailable?.();
    return;
}

این try تشریفاتی نیست. مرورگری که WebGL در آن خاموش است هیچ contextی به cobe نمی‌دهد و cobe خطا پرتاب می‌کند؛ پیش‌تر همین خطای گرفته‌نشده کل صفحه را unmount می‌کرد. حالا صفحه از راه onUnavailable فقط بخش کره را برمی‌دارد و جست‌وجو و کارت‌ها کار می‌کنند.

در config مقدار markers: [] آمده است: هر رله یک div است که هر فریم جابه‌جا می‌شود تا بشود رویش hover یا tap کرد. Framer Motion بقیه‌ی صفحه را متحرک می‌کند؛ cobe حلقه‌ی خودش را دارد.

قرارداد داده

مرورگر مستقیم سراغ backend نمی‌رود. از route handler /api/nodes/ می‌پرسد که هر درخواست به backend را روی سرور با HMAC امضا می‌کند، با کلیدی که در متغیرهای محیطی سرور می‌ماند، و صفحه‌بندی را دنبال می‌کند تا کره همه‌ی نشانگرها را یک‌جا بگیرد:

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

// … inside GET():
while (url && page < MAX_NODE_PAGES) {
    const data: BackendNodesPage = await backendRequest<BackendNodesPage>(url);
    nodes.push(...(data.results ?? []));
    count = data.count ?? nodes.length;
    timestamp = data.timestamp ?? timestamp;
    url = data.next;
    page += 1;
}

return NextResponse.json(
    {count, results: nodes, timestamp, truncated: Boolean(url)},
    {headers: {"Cache-Control": "no-store"}}
);

سمت کلاینت، toNodeData ردیف‌ها را به شکل داده‌ی کامپوننت درمی‌آورد؛ اگر درخواست شکست بخورد، کره بی‌نشانگر می‌چرخد.

نسخه‌ی تزئینی فهرستی JSON از lat، lng و title را در یک data attribute می‌گیرد، با سه قاعده که شکستن هرکدام بی‌صدا چیزی را خراب می‌کند:

  1. سقف 64. shader کتابخانه‌ی cobe آرایه‌ای uniform از vec4 با طول 64*2 دارد؛ نشانگر شصت‌وپنجم را GPU بی‌هیچ خطایی دور می‌اندازد. فهرست باید سمت سرور کوتاه شود، جایی که این حد در کد پیداست.
  2. فقط آنچه باید کشیده شود: ردیف‌های فعال و حذف‌نشده.
  3. escape برای attribute. یک " خام attribute را می‌بندد؛ autoescape باید روشن بماند، JSON باشد یا نه.

attribute را انتخاب کردم چون CSP سخت‌گیر برای اسکریپت درون‌خطی nonce می‌خواهد و attribute فقط escape درست. اگر صفحه cache شود، نقطه‌ی تازه تا انقضای cache منتظر می‌ماند؛ گیج‌کننده‌ترین بخش این کار همین است.

حلقه‌ی انیمیشن

دو حلقه کنار هم کار می‌کنند. cobe هر فریم onRender(state) را صدا می‌زند و کامپوننت state.phi را در آن می‌نویسد. حلقه‌ی دوم requestAnimationFrame هر رله را روی صفحه نگاشت می‌کند و کمان‌ها را از همان موقعیت‌ها می‌کشد تا هر خط دقیقاً روی نقطه‌های خودش بنشیند. نسخه‌ی React در هر فریم گامی ثابت می‌چرخد؛ نسخه‌ی تزئینی بر حسب میلی‌ثانیه (ROTATION_PER_MS = 0.0001)، پس نمایشگر سریع‌تر کره را تندتر نمی‌چرخاند.

هر کمان از شهری تا شهر دیگر رشد می‌کند، می‌ماند و محو می‌شود؛ نسخه‌ی React حداکثر پنج کمان هم‌زمان دارد و نسخه‌ی تزئینی چهار. سه جزئیات از تماشای خرابی‌اش آمد:

const facing = positions.filter((pos) => pos.z > ARC_SETTINGS.limbFade);
if (arcsRef.current.length < ARC_SETTINGS.maxArcs && facing.length >= 2) {
    const fromIndex = Math.floor(Math.random() * facing.length);
    let toIndex = Math.floor(Math.random() * (facing.length - 1));
    if (toIndex >= fromIndex) toIndex += 1;
    // … pick the arc's colour, then:
    arcsRef.current.push({
        fromId: facing[fromIndex].node.id,
        toId: facing[toIndex].node.id,
        born: now,
        rgb,
    });
}
  • دو سرِ کمان فقط از نیمه‌ی رو به بیننده انتخاب می‌شوند. انتخاب از کل فهرست حدود سه‌چهارم کمان‌ها را پشت کره به دنیا می‌آورد، جایی که هرگز دیده نمی‌شدند.
  • کمان نزدیک لبه‌ی کره کم‌نور می‌شود، به جای اینکه بین دو فریم غیب شود.
  • منحنی از مرکز قرص به بیرون خم می‌شود تا از روی کره بگذرد نه از درونش؛ و چون منحنی درجه‌دو رسم جزئی ندارد، خطِ در حال رشد در 48 گام پیموده می‌شود.

چهار محافظ

1. context مربوط به WebGL را زودتر از cobe بگیرید

cobe نوع context را با صدا زدن getContext("webgl2") و بعد "webgl" پیدا می‌کند و تازه بعد از آن همان نوع را با attributeهایش از phenomenon می‌خواهد. هر canvas در عمرش فقط یک context دارد، پس آن attributeها دور ریخته می‌شوند و دادن preserveDrawingBuffer از طریق cobe اثری ندارد. نسخه‌ی تزئینی context را پیش‌دستانه خودش می‌سازد:

function claimContext(canvas) {
    const attributes = {
        alpha: true,
        antialias: true,
        depth: false,
        stencil: false,
        preserveDrawingBuffer: true,
    };
    try {
        return (
            canvas.getContext('webgl2', attributes) ||
            canvas.getContext('webgl', attributes) ||
            canvas.getContext('experimental-webgl', attributes)
        );
    } catch (error) {
        return null;
    }
}

probe کتابخانه همین context را پیدا می‌کند و نگه می‌دارد. بدون بافر حفظ‌شده، canvas بعد از فریم خواندنی نیست و محافظ دوم کد مرده می‌شد. ترتیب probe و سقف 64 از bundle ساخته‌شده خوانده شده‌اند، نه از API مستند؛ بعد از هر ارتقا باید دوباره بررسی شوند.

2. هیچ‌وقت مربع سفید نشان ندهید

روی بعضی دستگاه‌ها canvas خالی WebGL سفید composite می‌شود، پس کره با opacity: 0 شروع می‌کند. شمردن فریم‌ها برای نمایان کردنش کافی نیست، چون canvas ممکن است کشیده‌شده گزارش شود و هنوز خالی باشد. نسخه‌ی تزئینی کره را در canvasی 24×24 کپی می‌کند و پیکسل‌هایش را می‌خواند: بعد از 4 فریم، چون نقشه از راه یک Image می‌رسد؛ حداکثر هر 150 میلی‌ثانیه، چون readback از GPU است؛ و اگر 5 ثانیه سفید بماند، کره را پنهان و نابود می‌کند. looksBlank فقط وقتی «خالی» می‌گوید که بیش از 90 درصد پیکسل‌ها مات و بیش از 90 درصدِ آن‌ها تقریباً سفید باشند؛ بافر ناخوانا سالم حساب می‌شود، چون پنهان کردن کره با خوانشی غلط معامله‌ی بدتری است.

کامپوننت React فقط بعد از 100 میلی‌ثانیه canvas را نمایان می‌کند؛ وقتی این تایمر به پرچمی وابسته بود که effect دیگری پایینش می‌آورد، کره گاهی برای همیشه نامرئی می‌ماند.

3. همه‌ی مختصات را اعتبارسنجی کنید

مختصات خراب خطا نمی‌دهد؛ NaN می‌شود و بی‌صدا هر کمانی را که به آن نقطه برسد خراب می‌کند. هر دو نسخه ردیفی را که lat یا lng آن متناهی نیست یا از بازه بیرون است کنار می‌گذارند. نقطه‌های cobe هم نقشه‌ی واقعی جهان‌اند و نقطه‌ی ساختگی وسط اقیانوس می‌نشیند.

4. چیزی را که کسی نمی‌بیند راه نیندازید

در نسخه‌ی تزئینی، ستون هیرو زیر 1200 پیکسل display: none است و کانتینر کره 0×0 می‌شود؛ راه انداختن کره یعنی یک context و یک حلقه‌ی رندر برای هیچ:

function ensureGlobe() {
    if (created || failed || width <= 1 || height <= 1) {
        return;
    }
    try {
        created = start();
    } catch (error) {
        failed = true;
    }
}

یک listener روی resize دوباره صدایش می‌زند و با چرخاندن گوشی کره بالا می‌آید. همتای این محافظ در React: وقتی رله‌ای نیست، حلقه‌ای که قبلاً کل کامپوننت را ثانیه‌ای 60 بار رندر می‌کرد اصلاً اجرا نمی‌شود.

دسترس‌پذیری و کاهش حرکت

نسخه‌ی تزئینی aria-hidden است، رویداد اشاره‌گر نمی‌گیرد و tooltip ندارد. نسخه‌ی React تعاملی است و فقط canvas کمان‌هایش aria-hidden دارد. همین نسخه دو درس داد: روی صفحه‌های پهن کره به بالا سرریز می‌کرد و کلیک‌های کادر جست‌وجو را می‌بلعید، پس محتوای بالای آن حالا z-index بالاتری دارد؛ و روی صفحه‌ی لمسی، کشیدن عمودی به صفحه می‌رسد، نه به کره‌ای با ارتفاع تا 1200 پیکسل.

با prefers-reduced-motion: reduce، نسخه‌ی تزئینی چرخش و کمان‌ها را متوقف می‌کند و کره بی‌حرکت سر جایش می‌ماند:

const reduced = window.matchMedia('(prefers-reduced-motion: reduce)');

// … inside cobe's onRender:
if (!reduced.matches) {
    phi += ROTATION_PER_MS * delta;
    if (now - lastSpawn > SPAWN_EVERY) {
        lastSpawn = now;
        spawnArc(now);
    }
}
state.phi = phi;

کامپوننت React این query را فقط پیش از ساختن کمان بررسی می‌کند، پس همچنان می‌چرخد. این کمبودی است که نسخه‌ی تزئینی راه بستنش را نشان می‌دهد.

نکته‌های کارایی

  • حدود 13 کیلوبایت کد شخص ثالث.
  • readback محدود است، روی کپی 24×24 کار می‌کند و بعد از نمایان شدن کره متوقف می‌شود.
  • canvas کمان‌ها devicePixelRatio را روی 2 محدود می‌کند.
  • hit-test موقعیت‌ها را از یک ref می‌خواند، پس listenerها هر فریم از نو ثبت نمی‌شوند.
  • نشانگرهای DOM هر فریم یک رندر React خرج دارند؛ بهای hover همین است.

بررسی درستی کار

requestAnimationFrame در تب پنهان اجرا نمی‌شود، پس آنجا کره‌ی سالم مرده به نظر می‌رسد. در عوض قرارداد داده بدون WebGL هم تست‌پذیر است: تست‌های سمت سرور ردیف‌های فعال، سقف 64، متناهی بودن مختصات، escape شدن عنوان و خروجی [] برای جدول خالی را بررسی می‌کنند.