glubokoe-intervyu/design-system/kt-ai-orb.js

139 lines
7.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Орб агента — монохромное точечное облако на canvas.
*
* ЗАЧЕМ СВОЙ, А НЕ ГОТОВЫЙ. Идея взята у thinking-orbs (MIT, Jakub Antalik &
* Alex Brinza), но сам пакет не подошёл по устройству: его сборка импортирует
* React на верхнем уровне, а HTML app-shell работает без React. Поставить его
* только в кит значило бы развести рантаймы — один и тот же контракт показывал
* бы разный индикатор, а это ровно тот разрыв паритета, который запрещает
* гейт G7. Плюс у ДС политика «внешних зависимостей нет»: вендоринг
* минифицированного бандла положил бы в систему код, который никто в команде
* не сможет поддерживать.
*
* ЦВЕТ. Ни одного значения цвета внутри: точки рисуются currentColor элемента.
* Значит орб темизуется теми же токенами, что и всё остальное, и работает в
* обеих темах без единой строки про тему.
*
* Подключение:
* <canvas class="kt-ai-orb" data-state="thinking" width="16" height="16"></canvas>
* ktAiOrb.mountAll(); // или ktAiOrb.mount(canvas)
*
* Состояния: idle (медленное дыхание), thinking (вращение), listening (пульс).
*/
(function (global) {
"use strict";
/* Состояния различаются ДВИЖЕНИЕМ, а не формой. Раньше здесь был параметр
spread, сжимавший сферу по вертикали (0.55 у idle) — облако читалось не как
шар, а как лужа, и на витрине это было видно сразу. Силуэт должен
оставаться круглым во всех состояниях, меняется только характер движения. */
var СОСТОЯНИЯ = {
idle: { speed: 0.14, pulse: 0.10 }, // медленно дышит
thinking: { speed: 0.85, pulse: 0.03 }, // уверенно вращается
listening: { speed: 0.30, pulse: 0.30 }, // заметно пульсирует
};
/* Два размера — два отдельных рисунка, а не масштаб одного.
На 22px 44 точки радиусом меньше пикселя сливались в серую кашу: точек
должно быть меньше, а каждая — крупнее и чётче. */
var РАЗМЕРЫ = [
{ до: 32, точек: 26, точка: 1.15, радиус: 0.80 },
{ до: 1e9, точек: 92, точка: 1.70, радиус: 0.84 },
];
function пресет(size) {
for (var i = 0; i < РАЗМЕРЫ.length; i++) if (size <= РАЗМЕРЫ[i].до) return РАЗМЕРЫ[i];
return РАЗМЕРЫ[РАЗМЕРЫ.length - 1];
}
/** Точки, равномерно разложенные по сфере (решётка Фибоначчи).
* Случайные точки дают комки — на 40 точках это видно сразу. */
function точкиСферы(n) {
var pts = [], φ = Math.PI * (3 - Math.sqrt(5));
for (var i = 0; i < n; i++) {
var y = 1 - (i / (n - 1)) * 2;
var r = Math.sqrt(Math.max(0, 1 - y * y));
var θ = φ * i;
pts.push([Math.cos(θ) * r, y, Math.sin(θ) * r]);
}
return pts;
}
function mount(canvas) {
if (!canvas || canvas.__ktOrb) return;
var ctx = canvas.getContext("2d");
if (!ctx) return;
var reduce = global.matchMedia &&
global.matchMedia("(prefers-reduced-motion: reduce)").matches;
var size = canvas.clientWidth || parseInt(canvas.getAttribute("width"), 10) || 16;
var ps = пресет(size);
var pts = точкиСферы(ps.точек);
var t0 = null, raf = 0;
function resize() {
var dpr = global.devicePixelRatio || 1;
size = canvas.clientWidth || size;
canvas.width = Math.round(size * dpr);
canvas.height = Math.round(size * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function кадр(ts) {
if (t0 === null) t0 = ts;
var t = (ts - t0) / 1000;
var st = СОСТОЯНИЯ[canvas.getAttribute("data-state")] || СОСТОЯНИЯ.idle;
// Цвет — только currentColor: ни одного литерала в файле.
var ink = global.getComputedStyle(canvas).color;
var m = ink.match(/\d+/g) || [26, 26, 26];
ctx.clearRect(0, 0, size, size);
var c = size / 2;
var R = c * ps.радиус * (1 + Math.sin(t * 1.6) * st.pulse * 0.14);
var a = t * st.speed, ca = Math.cos(a), sa = Math.sin(a);
var tilt = 0.42, ct = Math.cos(tilt), stl = Math.sin(tilt);
var dot = ps.точка;
for (var i = 0; i < pts.length; i++) {
var x = pts[i][0], y = pts[i][1], z = pts[i][2];
var x1 = x * ca - z * sa, z1 = x * sa + z * ca; // поворот вокруг Y
var y1 = y * ct - z1 * stl, z2 = y * stl + z1 * ct; // наклон оси
// Дальние точки тусклее и мельче — это и создаёт объём.
var depth = (z2 + 1) / 2;
ctx.globalAlpha = 0.18 + depth * 0.62;
ctx.fillStyle = "rgb(" + m[0] + "," + m[1] + "," + m[2] + ")";
ctx.beginPath();
ctx.arc(c + x1 * R, c + y1 * R, Math.max(0.6, dot * (0.62 + depth * 0.52)), 0, 6.2832);
ctx.fill();
}
ctx.globalAlpha = 1;
if (!reduce) raf = global.requestAnimationFrame(кадр);
}
resize();
// ПЕРВЫЙ КАДР — СИНХРОННО, до всякого rAF. Браузер не вызывает
// requestAnimationFrame в фоновой вкладке, и без этой строки орб в неактивном
// окне не рисовался вообще: не «замирал», а оставался пустым прямоугольником.
// То же самое даёт корректное поведение при prefers-reduced-motion — статичный
// кадр вместо пустоты.
кадр(0); // следующий кадр ставит себе он сам — второй раз планировать нельзя
canvas.__ktOrb = {
stop: function () { global.cancelAnimationFrame(raf); canvas.__ktOrb = null; },
resize: resize,
frame: кадр, // чтобы поведение можно было проверить без rAF
};
}
function mountAll(root) {
var nodes = (root || global.document).querySelectorAll("canvas.kt-ai-orb");
for (var i = 0; i < nodes.length; i++) mount(nodes[i]);
}
global.ktAiOrb = { mount: mount, mountAll: mountAll };
if (global.document && global.document.readyState !== "loading") mountAll();
else if (global.document) global.document.addEventListener("DOMContentLoaded", function () { mountAll(); });
})(typeof window !== "undefined" ? window : this);