870 lines
27 KiB
JavaScript
870 lines
27 KiB
JavaScript
const DEFAULT_BLUE_SRC = "/static/logo2.png";
|
|
const LOGO_SRC_WIDTH = 2222;
|
|
const LOGO_SRC_HEIGHT = 1024;
|
|
/** ~28 строк сетки, как у старого logo2 (305px @ cell 11). */
|
|
const INITIAL_CELL = 37;
|
|
const DURATION_MS = 4750;
|
|
const WAVE2_START_MS = 0.36 * 4500;
|
|
const WAVE2_SPAN_MS = 0.34 * 6800;
|
|
const WAVE2_START = WAVE2_START_MS / DURATION_MS;
|
|
const WAVE2_SPAN = WAVE2_SPAN_MS / DURATION_MS;
|
|
const WAVE2_END = (WAVE2_START_MS + WAVE2_SPAN_MS) / DURATION_MS;
|
|
const HOLD_END = 0.94;
|
|
const PIXEL_SCALE = WAVE2_START / 0.36;
|
|
const WAVE0_START = 0.04 * PIXEL_SCALE;
|
|
const WAVE0_SPAN = 0.20 * PIXEL_SCALE;
|
|
const WAVE1_START = WAVE0_START + WAVE0_SPAN - 0.04 * PIXEL_SCALE;
|
|
const WAVE1_SPAN = 0.20 * PIXEL_SCALE;
|
|
const PIXEL_FADE = 0.012;
|
|
const PIXEL_GAP = 1;
|
|
const ASSEMBLY_FEATHER = 10;
|
|
const COLOR_CROSSFADE = 0.024;
|
|
const SHIMMER_MS = 1400;
|
|
const SHIMMER_PROGRESS_START = 88;
|
|
const BRAND_BLUE = [44, 123, 229];
|
|
const BRAND_ORGANIC = [72, 129, 109];
|
|
const BRAND_ORGANIC_HC = [47, 107, 87];
|
|
const BRAND_WHITE = [238, 240, 243];
|
|
const PIXEL_GRAY = [148, 152, 158];
|
|
const BG = [246, 246, 244];
|
|
const GRID_STEP = 10;
|
|
|
|
function shimmerDurationMs() {
|
|
return SHIMMER_MS;
|
|
}
|
|
|
|
function wave2EndMs(durationMs) {
|
|
return WAVE2_END * durationMs;
|
|
}
|
|
|
|
function animationEndMs(durationMs) {
|
|
return Math.max(durationMs, wave2EndMs(durationMs) + SHIMMER_MS + 180);
|
|
}
|
|
|
|
/** Фактическая длительность проигрывания (с учётом totalDurationMs). */
|
|
function resolvePlaybackEndMs(durationMs, totalDurationMs) {
|
|
const natural = animationEndMs(durationMs);
|
|
if (totalDurationMs == null || !Number.isFinite(totalDurationMs)) {
|
|
return natural;
|
|
}
|
|
return Math.min(natural, Math.max(400, totalDurationMs));
|
|
}
|
|
|
|
export const LOGO_LOADER_DURATION_MS = DURATION_MS;
|
|
export { animationEndMs, resolvePlaybackEndMs, drawShimmer, LOGO_SRC_WIDTH, LOGO_SRC_HEIGHT };
|
|
|
|
/** Буфер логотипа для navbar-shimmer (выравнивание слева, как object-position: left). */
|
|
export function createNavLogoShimmerBuffer(img, slotW, slotH, inkRgb = [255, 255, 255]) {
|
|
const srcW = img.naturalWidth || LOGO_SRC_WIDTH;
|
|
const srcH = img.naturalHeight || LOGO_SRC_HEIGHT;
|
|
const scale = Math.min(slotW / srcW, slotH / srcH);
|
|
const drawWidth = srcW * scale;
|
|
const drawHeight = srcH * scale;
|
|
const layout = {
|
|
offsetX: 0,
|
|
offsetY: (slotH - drawHeight) / 2,
|
|
drawWidth,
|
|
drawHeight,
|
|
};
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = slotW;
|
|
canvas.height = slotH;
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.clearRect(0, 0, slotW, slotH);
|
|
ctx.drawImage(img, layout.offsetX, layout.offsetY, drawWidth, drawHeight);
|
|
knockoutBackgroundFromLogo(canvas, slotW, slotH);
|
|
if (inkRgb) recolorVisiblePixels(canvas, slotW, slotH, inkRgb);
|
|
return { logoCanvas: canvas, layout };
|
|
}
|
|
|
|
function prefersReducedMotion() {
|
|
return globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false;
|
|
}
|
|
|
|
/** light | organic | dark — для цвета логотипа в анимации загрузки */
|
|
function resolveLoaderTheme(options = {}) {
|
|
const explicit = options.theme;
|
|
if (explicit === "light" || explicit === "organic" || explicit === "dark") {
|
|
return explicit;
|
|
}
|
|
const docTheme = document.documentElement.getAttribute("data-theme");
|
|
if (docTheme === "light" || docTheme === "organic" || docTheme === "dark") {
|
|
return docTheme;
|
|
}
|
|
try {
|
|
const saved = localStorage.getItem("theme");
|
|
if (saved === "light" || saved === "organic" || saved === "dark") return saved;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
if (options.darkTheme) return "dark";
|
|
return "light";
|
|
}
|
|
|
|
function prefersHighContrast() {
|
|
const attr = document.documentElement.getAttribute("data-high-contrast");
|
|
if (attr === "true") return true;
|
|
if (attr === "false") return false;
|
|
return globalThis.matchMedia?.("(prefers-contrast: more)")?.matches ?? false;
|
|
}
|
|
|
|
function loaderAccentRgb(theme) {
|
|
if (theme === "dark") return BRAND_WHITE;
|
|
if (theme === "organic") return prefersHighContrast() ? BRAND_ORGANIC_HC : BRAND_ORGANIC;
|
|
return BRAND_BLUE;
|
|
}
|
|
|
|
function loaderUsesDarkShimmer(theme) {
|
|
return theme === "dark";
|
|
}
|
|
|
|
function loadImage(src) {
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
img.onload = () => resolve(img);
|
|
img.onerror = () => reject(new Error(`Failed to load image: ${src}`));
|
|
img.src = src;
|
|
});
|
|
}
|
|
|
|
function isLogoInk(data, index) {
|
|
const r = data[index];
|
|
const g = data[index + 1];
|
|
const b = data[index + 2];
|
|
const a = data[index + 3];
|
|
if (a < 20) return false;
|
|
if (r + g + b < 80) return false;
|
|
return b > 70 && b >= r && b >= g * 0.85;
|
|
}
|
|
|
|
function isLogoBlueFringe(data, index) {
|
|
const r = data[index];
|
|
const g = data[index + 1];
|
|
const b = data[index + 2];
|
|
const a = data[index + 3];
|
|
if (a < 1) return false;
|
|
if (isLogoInk(data, index)) return true;
|
|
return b > r + 5 && b > g - 2 && b > 68 && r + g + b < 700;
|
|
}
|
|
|
|
function knockoutBackgroundFromLogo(canvas, width, height) {
|
|
const ctx = canvas.getContext("2d");
|
|
const imageData = ctx.getImageData(0, 0, width, height);
|
|
const data = imageData.data;
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
if (data[i + 3] < 1) continue;
|
|
if (isLogoBlueFringe(data, i)) continue;
|
|
data[i + 3] = 0;
|
|
}
|
|
ctx.putImageData(imageData, 0, 0);
|
|
}
|
|
|
|
function recolorVisiblePixels(canvas, width, height, rgb) {
|
|
const ctx = canvas.getContext("2d");
|
|
const imageData = ctx.getImageData(0, 0, width, height);
|
|
const data = imageData.data;
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
if (data[i + 3] < 1) continue;
|
|
data[i] = rgb[0];
|
|
data[i + 1] = rgb[1];
|
|
data[i + 2] = rgb[2];
|
|
}
|
|
ctx.putImageData(imageData, 0, 0);
|
|
}
|
|
|
|
function cloneLogoBuffer(sourceCanvas, width, height, rgb = null) {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.drawImage(sourceCanvas, 0, 0);
|
|
if (rgb) recolorVisiblePixels(canvas, width, height, rgb);
|
|
return canvas;
|
|
}
|
|
|
|
function drawLogoToBuffer(img, width, height, knockoutBackground = false) {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.clearRect(0, 0, width, height);
|
|
|
|
const scale = Math.min(width / img.naturalWidth, height / img.naturalHeight);
|
|
const drawWidth = img.naturalWidth * scale;
|
|
const drawHeight = img.naturalHeight * scale;
|
|
const offsetX = (width - drawWidth) / 2;
|
|
const offsetY = (height - drawHeight) / 2;
|
|
ctx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight);
|
|
if (knockoutBackground) knockoutBackgroundFromLogo(canvas, width, height);
|
|
return canvas;
|
|
}
|
|
|
|
function hashCell(col, row) {
|
|
return ((col * 92837111) ^ (row * 689287499)) >>> 0;
|
|
}
|
|
|
|
function easeOutQuad(t) {
|
|
return 1 - (1 - t) * (1 - t);
|
|
}
|
|
|
|
function easeInOutQuad(t) {
|
|
return t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
|
|
}
|
|
|
|
function rgbaString(r, g, b, a) {
|
|
return `rgba(${r | 0}, ${g | 0}, ${b | 0}, ${a})`;
|
|
}
|
|
|
|
function desaturateColor(r, g, b, amount = 0.72) {
|
|
const gray = (r + g + b) / 3;
|
|
return [
|
|
r + (gray - r) * amount,
|
|
g + (gray - g) * amount,
|
|
b + (gray - b) * amount,
|
|
];
|
|
}
|
|
|
|
function lerpColor(c1, c2, t) {
|
|
return [
|
|
c1[0] + (c2[0] - c1[0]) * t,
|
|
c1[1] + (c2[1] - c1[1]) * t,
|
|
c1[2] + (c2[2] - c1[2]) * t,
|
|
];
|
|
}
|
|
|
|
function colorCss(color) {
|
|
return `rgb(${color[0] | 0}, ${color[1] | 0}, ${color[2] | 0})`;
|
|
}
|
|
|
|
function displayProgress(t, elapsedMs = null, durationMs = DURATION_MS) {
|
|
const shimmerStartMs = wave2EndMs(durationMs);
|
|
const shimmerEndMs = shimmerStartMs + shimmerDurationMs();
|
|
|
|
if (elapsedMs != null && elapsedMs >= shimmerStartMs) {
|
|
if (elapsedMs >= shimmerEndMs) return 100;
|
|
const shimmerP = (elapsedMs - shimmerStartMs) / shimmerDurationMs();
|
|
return Math.round(
|
|
SHIMMER_PROGRESS_START + shimmerP * (100 - SHIMMER_PROGRESS_START),
|
|
);
|
|
}
|
|
|
|
if (t >= WAVE2_END) return SHIMMER_PROGRESS_START;
|
|
if (t >= WAVE2_START) {
|
|
const p = (t - WAVE2_START) / (WAVE2_END - WAVE2_START);
|
|
return Math.round(
|
|
65 + Math.min(1, p) * (SHIMMER_PROGRESS_START - 65),
|
|
);
|
|
}
|
|
if (t >= WAVE1_START) {
|
|
const wave1End = WAVE1_START + WAVE1_SPAN;
|
|
const p = Math.min(1, (t - WAVE1_START) / Math.max(0.001, wave1End - WAVE1_START));
|
|
return Math.round(35 + p * 30);
|
|
}
|
|
if (t >= WAVE0_START) {
|
|
const wave0End = WAVE0_START + WAVE0_SPAN;
|
|
const p = Math.min(1, (t - WAVE0_START) / Math.max(0.001, wave0End - WAVE0_START));
|
|
return Math.round(p * 35);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function progressBarColor(progress, loaderTheme = "light") {
|
|
const accent = loaderAccentRgb(loaderTheme);
|
|
if (progress >= 65) return colorCss(accent);
|
|
if (progress >= 35) {
|
|
const p = (progress - 35) / 30;
|
|
return colorCss(lerpColor(PIXEL_GRAY, accent, p));
|
|
}
|
|
return colorCss(PIXEL_GRAY);
|
|
}
|
|
|
|
function withLogoTransform(ctx, layout, t, drawFn) {
|
|
drawFn();
|
|
}
|
|
|
|
/** Фаза 0 — серые пиксели; фаза 1 — синие/белые; обе слева направо. */
|
|
function buildLogoCells(logoData, logoWidth, logoHeight, cell, accentColor = null) {
|
|
const cols = Math.ceil(logoWidth / cell);
|
|
const rows = Math.ceil(logoHeight / cell);
|
|
const colDenom = Math.max(1, cols - 1);
|
|
const cells = [];
|
|
|
|
for (let row = 0; row < rows; row += 1) {
|
|
for (let col = 0; col < cols; col += 1) {
|
|
const cx = Math.min(logoWidth - 1, col * cell + Math.floor(cell / 2));
|
|
const cy = Math.min(logoHeight - 1, row * cell + Math.floor(cell / 2));
|
|
const index = (cy * logoWidth + cx) * 4;
|
|
if (!isLogoInk(logoData, index)) continue;
|
|
|
|
const r = logoData[index];
|
|
const g = logoData[index + 1];
|
|
const b = logoData[index + 2];
|
|
const noise = (hashCell(col, row) % 1000) / 1000;
|
|
const sweep = Math.max(0, Math.min(1, col / colDenom + (noise - 0.5) * 0.028));
|
|
const color = accentColor ?? [r, g, b];
|
|
|
|
cells.push({
|
|
col,
|
|
row,
|
|
wave0At: WAVE0_START + sweep * WAVE0_SPAN,
|
|
wave1At: WAVE1_START + sweep * WAVE1_SPAN,
|
|
color,
|
|
gray: desaturateColor(r, g, b),
|
|
});
|
|
}
|
|
}
|
|
|
|
return cells;
|
|
}
|
|
|
|
function wave2FrontX(t, layout) {
|
|
if (t < WAVE2_START) {
|
|
return layout.offsetX;
|
|
}
|
|
const progress = easeInOutQuad(Math.min(1, (t - WAVE2_START) / WAVE2_SPAN));
|
|
return layout.offsetX + layout.drawWidth * progress;
|
|
}
|
|
|
|
function drawBackground(ctx, width, height, layout, t, plain = false, transparent = false) {
|
|
if (transparent) {
|
|
ctx.clearRect(0, 0, width, height);
|
|
return;
|
|
}
|
|
|
|
if (plain) {
|
|
ctx.fillStyle = "#ffffff";
|
|
ctx.fillRect(0, 0, width, height);
|
|
return;
|
|
}
|
|
|
|
const cx = width / 2;
|
|
const cy = layout ? layout.offsetY + layout.drawHeight / 2 : height * 0.44;
|
|
const bgGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(width, height) * 0.72);
|
|
bgGrad.addColorStop(0, "#FAFAF8");
|
|
bgGrad.addColorStop(1, "#F0F0EE");
|
|
ctx.fillStyle = bgGrad;
|
|
ctx.fillRect(0, 0, width, height);
|
|
|
|
const animating = t > 0 && t < HOLD_END;
|
|
const logoCx = layout ? layout.offsetX + layout.drawWidth / 2 : cx;
|
|
const logoCy = layout ? layout.offsetY + layout.drawHeight / 2 : cy;
|
|
const focusRadius = layout
|
|
? Math.max(layout.drawWidth, layout.drawHeight) * 0.72
|
|
: Math.min(width, height) * 0.35;
|
|
|
|
for (let x = GRID_STEP / 2; x < width; x += GRID_STEP) {
|
|
for (let y = GRID_STEP / 2; y < height; y += GRID_STEP) {
|
|
let alpha = 0.055;
|
|
if (layout && animating) {
|
|
const dist = Math.hypot(x - logoCx, y - logoCy);
|
|
if (dist < focusRadius) {
|
|
alpha += 0.045 * (1 - dist / focusRadius);
|
|
}
|
|
}
|
|
ctx.fillStyle = `rgba(26, 30, 28, ${alpha})`;
|
|
ctx.fillRect(x, y, 1, 1);
|
|
}
|
|
}
|
|
|
|
const vig = ctx.createRadialGradient(cx, cy, Math.min(width, height) * 0.28, cx, cy, Math.max(width, height) * 0.82);
|
|
vig.addColorStop(0, "rgba(26, 30, 28, 0)");
|
|
vig.addColorStop(1, "rgba(26, 30, 28, 0.07)");
|
|
ctx.fillStyle = vig;
|
|
ctx.fillRect(0, 0, width, height);
|
|
}
|
|
|
|
function ensureLoaderChrome(root, hideChrome = false) {
|
|
let canvas = root.querySelector(".wesp-logo-loader__canvas");
|
|
let footer = root.querySelector(".wesp-logo-loader__footer");
|
|
|
|
if (!canvas) {
|
|
canvas = document.createElement("canvas");
|
|
canvas.className = "wesp-logo-loader__canvas";
|
|
root.appendChild(canvas);
|
|
}
|
|
|
|
if (!footer && !hideChrome) {
|
|
footer = document.createElement("div");
|
|
footer.className = "wesp-logo-loader__footer";
|
|
footer.innerHTML = `
|
|
<div class="wesp-logo-loader__footer-row">
|
|
<span class="wesp-logo-loader__label">Загрузка</span>
|
|
<span class="wesp-logo-loader__percent">00%</span>
|
|
</div>
|
|
<div class="wesp-logo-loader__bar">
|
|
<div class="wesp-logo-loader__bar-fill"></div>
|
|
</div>
|
|
<p class="wesp-logo-loader__tagline" aria-live="polite"></p>`;
|
|
root.appendChild(footer);
|
|
}
|
|
|
|
return {
|
|
canvas,
|
|
percentEl: footer?.querySelector(".wesp-logo-loader__percent") ?? null,
|
|
barFill: footer?.querySelector(".wesp-logo-loader__bar-fill") ?? null,
|
|
taglineEl: footer?.querySelector(".wesp-logo-loader__tagline") ?? null,
|
|
};
|
|
}
|
|
|
|
function computeLogoLayout(viewport, logoWidth, logoHeight, embedded = false) {
|
|
const maxWidth = viewport.w * (embedded ? 0.94 : 0.88);
|
|
const maxHeight = viewport.h * (embedded ? 0.88 : 0.34);
|
|
const scale = Math.min(maxWidth / logoWidth, maxHeight / logoHeight);
|
|
const drawWidth = logoWidth * scale;
|
|
const drawHeight = logoHeight * scale;
|
|
const offsetX = (viewport.w - drawWidth) / 2;
|
|
const offsetY = embedded
|
|
? (viewport.h - drawHeight) / 2
|
|
: viewport.h * 0.44 - drawHeight / 2;
|
|
|
|
return { scale, drawWidth, drawHeight, offsetX, offsetY };
|
|
}
|
|
|
|
function drawSharpLogo(ctx, offBlue, layout, alpha = 1) {
|
|
if (alpha <= 0) return;
|
|
ctx.save();
|
|
ctx.imageSmoothingEnabled = true;
|
|
ctx.globalAlpha = alpha;
|
|
ctx.drawImage(offBlue, layout.offsetX, layout.offsetY, layout.drawWidth, layout.drawHeight);
|
|
ctx.restore();
|
|
}
|
|
|
|
function pixelGrid(layout, cell, logoWidth, logoHeight) {
|
|
const cols = Math.ceil(logoWidth / cell);
|
|
const rows = Math.ceil(logoHeight / cell);
|
|
return {
|
|
originX: layout.offsetX,
|
|
originY: layout.offsetY,
|
|
cellW: layout.drawWidth / cols,
|
|
cellH: layout.drawHeight / rows,
|
|
};
|
|
}
|
|
|
|
function cellRect(grid, col, row) {
|
|
const x0 = grid.originX + col * grid.cellW;
|
|
const y0 = grid.originY + row * grid.cellH;
|
|
const x1 = grid.originX + (col + 1) * grid.cellW;
|
|
const y1 = grid.originY + (row + 1) * grid.cellH;
|
|
return {
|
|
x: Math.round(x0),
|
|
y: Math.round(y0),
|
|
w: Math.max(1, Math.round(x1) - Math.round(x0)),
|
|
h: Math.max(1, Math.round(y1) - Math.round(y0)),
|
|
};
|
|
}
|
|
|
|
function drawPixelBlock(ctx, rect, color, alpha) {
|
|
if (alpha <= 0) return;
|
|
const gap = Math.min(PIXEL_GAP, Math.max(0, rect.w - 1), Math.max(0, rect.h - 1));
|
|
const inset = gap * 0.5;
|
|
const x = rect.x + inset;
|
|
const y = rect.y + inset;
|
|
const w = Math.max(1, rect.w - gap);
|
|
const h = Math.max(1, rect.h - gap);
|
|
ctx.fillStyle = rgbaString(color[0], color[1], color[2], alpha);
|
|
ctx.fillRect(x, y, w, h);
|
|
}
|
|
|
|
function pixelAppear(t, startAt) {
|
|
const appear = Math.min(1, (t - startAt) / PIXEL_FADE);
|
|
return appear >= 0.7 ? 1 : appear;
|
|
}
|
|
|
|
function resolvePixelColor(item, t) {
|
|
if (t < item.wave1At) return item.gray;
|
|
const blend = Math.min(1, (t - item.wave1At) / COLOR_CROSSFADE);
|
|
if (blend >= 1) return item.color;
|
|
return lerpColor(item.gray, item.color, blend);
|
|
}
|
|
|
|
function resolvePixelAlpha(item, t) {
|
|
if (t < item.wave0At) return 0;
|
|
let alpha = pixelAppear(t, item.wave0At);
|
|
if (t >= item.wave1At) {
|
|
alpha = Math.max(alpha, pixelAppear(t, item.wave1At));
|
|
}
|
|
return alpha;
|
|
}
|
|
|
|
function featherPixelAlpha(alpha, cellMid, frontX, feather) {
|
|
if (cellMid <= frontX || cellMid >= frontX + feather) return alpha;
|
|
return alpha * ((cellMid - frontX) / feather);
|
|
}
|
|
|
|
function drawSharpLogoFeathered(ctx, logoCanvas, layout, frontX, feather) {
|
|
const left = layout.offsetX;
|
|
const top = layout.offsetY;
|
|
const width = layout.drawWidth;
|
|
const height = layout.drawHeight;
|
|
const softStart = Math.max(left, frontX - feather);
|
|
|
|
if (softStart > left + 0.5) {
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.rect(left, top, softStart - left, height);
|
|
ctx.clip();
|
|
drawSharpLogo(ctx, logoCanvas, layout, 1);
|
|
ctx.restore();
|
|
}
|
|
|
|
const bandLeft = softStart;
|
|
const bandRight = Math.min(left + width, frontX);
|
|
if (bandRight <= bandLeft + 0.5) return;
|
|
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.rect(bandLeft, top, bandRight - bandLeft, height);
|
|
ctx.clip();
|
|
drawSharpLogo(ctx, logoCanvas, layout, 1);
|
|
ctx.globalCompositeOperation = "destination-in";
|
|
const grad = ctx.createLinearGradient(bandLeft, 0, bandRight, 0);
|
|
grad.addColorStop(0, "rgba(255,255,255,1)");
|
|
grad.addColorStop(1, "rgba(255,255,255,0)");
|
|
ctx.fillStyle = grad;
|
|
ctx.fillRect(bandLeft, top, bandRight - bandLeft, height);
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawShimmer(ctx, layout, logoCanvas, shimmerT, loaderTheme = "light") {
|
|
const darkShimmer = loaderUsesDarkShimmer(loaderTheme);
|
|
const bandW = layout.drawWidth * (darkShimmer ? 0.36 : 0.14);
|
|
const x = layout.offsetX + (layout.drawWidth + bandW) * shimmerT - bandW;
|
|
const left = layout.offsetX;
|
|
const top = layout.offsetY;
|
|
const width = layout.drawWidth;
|
|
const height = layout.drawHeight;
|
|
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.rect(left, top, width, height);
|
|
ctx.clip();
|
|
|
|
if (darkShimmer) {
|
|
const [gr, gg, gb] = PIXEL_GRAY;
|
|
const grayWave = ctx.createLinearGradient(x, 0, x + bandW, 0);
|
|
grayWave.addColorStop(0, "rgba(255,255,255,0)");
|
|
grayWave.addColorStop(0.22, "rgba(255,255,255,0)");
|
|
grayWave.addColorStop(0.5, `rgba(${gr},${gg},${gb},0.95)`);
|
|
grayWave.addColorStop(0.78, "rgba(255,255,255,0)");
|
|
grayWave.addColorStop(1, "rgba(255,255,255,0)");
|
|
ctx.globalCompositeOperation = "source-atop";
|
|
ctx.fillStyle = grayWave;
|
|
ctx.fillRect(left, top, width, height);
|
|
ctx.restore();
|
|
return;
|
|
}
|
|
|
|
const peak = 0.72;
|
|
const highlight = ctx.createLinearGradient(x, 0, x + bandW, 0);
|
|
highlight.addColorStop(0, "rgba(255,255,255,0)");
|
|
highlight.addColorStop(0.3, "rgba(255,255,255,0)");
|
|
highlight.addColorStop(0.5, `rgba(255,255,255,${peak})`);
|
|
highlight.addColorStop(0.7, "rgba(255,255,255,0)");
|
|
highlight.addColorStop(1, "rgba(255,255,255,0)");
|
|
ctx.globalCompositeOperation = "lighter";
|
|
ctx.fillStyle = highlight;
|
|
ctx.fillRect(left, top, width, height);
|
|
ctx.globalCompositeOperation = "destination-in";
|
|
ctx.drawImage(logoCanvas, left, top, width, height);
|
|
ctx.restore();
|
|
}
|
|
|
|
/** Фаза 0 — серые; фаза 1 — цветные; фаза 2 — плавная сборка по контуру надписи. */
|
|
function drawWaves(ctx, layout, cell, cells, t, logoCanvas, logoWidth, logoHeight) {
|
|
const frontX = wave2FrontX(t, layout);
|
|
const sharpRight = layout.offsetX + layout.drawWidth;
|
|
const grid = pixelGrid(layout, cell, logoWidth, logoHeight);
|
|
const inWave2 = t >= WAVE2_START;
|
|
|
|
if (inWave2 && frontX > layout.offsetX + 1) {
|
|
drawSharpLogoFeathered(ctx, logoCanvas, layout, frontX, ASSEMBLY_FEATHER);
|
|
}
|
|
|
|
if (inWave2 && frontX >= sharpRight - 1) {
|
|
return;
|
|
}
|
|
|
|
for (const item of cells) {
|
|
if (t < item.wave0At) continue;
|
|
|
|
const rect = cellRect(grid, item.col, item.row);
|
|
const cellMid = rect.x + rect.w * 0.5;
|
|
if (inWave2 && cellMid < frontX - ASSEMBLY_FEATHER) continue;
|
|
|
|
let alpha = resolvePixelAlpha(item, t);
|
|
if (inWave2) {
|
|
alpha = featherPixelAlpha(alpha, cellMid, frontX, ASSEMBLY_FEATHER);
|
|
}
|
|
if (alpha <= 0) continue;
|
|
|
|
drawPixelBlock(ctx, rect, resolvePixelColor(item, t), alpha);
|
|
}
|
|
}
|
|
|
|
function updateProgressChrome(
|
|
percentEl,
|
|
barFill,
|
|
t,
|
|
getExternalProgress,
|
|
loaderTheme,
|
|
elapsedMs = null,
|
|
durationMs = DURATION_MS,
|
|
) {
|
|
let progress = displayProgress(t, elapsedMs, durationMs);
|
|
const external = getExternalProgress();
|
|
if (external != null) {
|
|
progress = Math.max(progress, Math.min(100, Math.round(external * 100)));
|
|
}
|
|
|
|
if (percentEl) percentEl.textContent = `${String(progress).padStart(2, "0")}%`;
|
|
if (barFill) {
|
|
barFill.style.width = `${progress}%`;
|
|
barFill.style.background = progressBarColor(progress, loaderTheme);
|
|
}
|
|
|
|
return progress;
|
|
}
|
|
|
|
export async function mountWespLogoLoader(root, options = {}) {
|
|
if (!root) {
|
|
throw new Error("mountWespLogoLoader: root element is required");
|
|
}
|
|
|
|
const blueSrc = options.blueSrc ?? options.whiteSrc ?? DEFAULT_BLUE_SRC;
|
|
const initialCell = options.cell ?? options.pixel ?? INITIAL_CELL;
|
|
const loop = options.loop !== false;
|
|
const durationMs = options.durationMs ?? DURATION_MS;
|
|
const totalDurationMs = options.totalDurationMs ?? null;
|
|
const playbackEndMs = resolvePlaybackEndMs(durationMs, totalDurationMs);
|
|
const embedded = !!options.embedded;
|
|
const plainBackground = !!options.plainBackground;
|
|
const transparentBackground = !!options.transparentBackground;
|
|
const knockoutBackground = !!options.knockoutBackground;
|
|
const loaderTheme = resolveLoaderTheme(options);
|
|
const staticLogo = !!options.staticLogo;
|
|
const hideChrome = !!options.hideChrome;
|
|
const onComplete = typeof options.onComplete === "function" ? options.onComplete : null;
|
|
const getExternalProgress = typeof options.progress === "function"
|
|
? options.progress
|
|
: () => (options.progress ?? null);
|
|
|
|
root.classList.add("wesp-logo-loader");
|
|
if (embedded) root.classList.add("wesp-logo-loader--embedded");
|
|
if (plainBackground) root.classList.add("wesp-logo-loader--plain");
|
|
if (transparentBackground) root.classList.add("wesp-logo-loader--transparent");
|
|
if (hideChrome) root.classList.add("wesp-logo-loader--no-chrome");
|
|
|
|
const { canvas, percentEl, barFill } = ensureLoaderChrome(root, hideChrome);
|
|
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
|
if (transparentBackground) {
|
|
canvas.style.background = "transparent";
|
|
}
|
|
|
|
const blueImg = await loadImage(blueSrc);
|
|
const logoWidth = blueImg.naturalWidth || LOGO_SRC_WIDTH;
|
|
const logoHeight = blueImg.naturalHeight || LOGO_SRC_HEIGHT;
|
|
const offBlue = drawLogoToBuffer(
|
|
blueImg,
|
|
logoWidth,
|
|
logoHeight,
|
|
knockoutBackground,
|
|
);
|
|
const accentRgb = loaderAccentRgb(loaderTheme);
|
|
const displayLogo = loaderTheme === "light"
|
|
? offBlue
|
|
: cloneLogoBuffer(offBlue, logoWidth, logoHeight, accentRgb);
|
|
const waveAccent = loaderTheme === "light" ? null : accentRgb;
|
|
const logoData = offBlue.getContext("2d").getImageData(0, 0, logoWidth, logoHeight).data;
|
|
const logoCells = buildLogoCells(
|
|
logoData,
|
|
logoWidth,
|
|
logoHeight,
|
|
initialCell,
|
|
waveAccent,
|
|
);
|
|
|
|
let viewport = { w: 0, h: 0, dpr: 1 };
|
|
let layout = null;
|
|
let rafId = 0;
|
|
let start = performance.now();
|
|
let disposed = false;
|
|
let completedFired = false;
|
|
|
|
function finishIfDone(elapsedMs) {
|
|
if (loop || elapsedMs < playbackEndMs || completedFired || !onComplete) return;
|
|
completedFired = true;
|
|
window.setTimeout(() => onComplete(), 120);
|
|
}
|
|
|
|
function readViewportSize() {
|
|
if (embedded) {
|
|
const rect = root.getBoundingClientRect();
|
|
return {
|
|
w: Math.max(1, rect.width || root.clientWidth || 1),
|
|
h: Math.max(1, rect.height || root.clientHeight || 1),
|
|
};
|
|
}
|
|
return { w: globalThis.innerWidth, h: globalThis.innerHeight };
|
|
}
|
|
|
|
function resize() {
|
|
const dpr = Math.min(globalThis.devicePixelRatio || 1, 2);
|
|
const size = readViewportSize();
|
|
viewport = { w: size.w, h: size.h, dpr };
|
|
|
|
canvas.width = viewport.w * dpr;
|
|
canvas.height = viewport.h * dpr;
|
|
canvas.style.width = `${viewport.w}px`;
|
|
canvas.style.height = `${viewport.h}px`;
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
ctx.imageSmoothingEnabled = false;
|
|
|
|
layout = computeLogoLayout(viewport, logoWidth, logoHeight, embedded);
|
|
}
|
|
|
|
function drawStaticLogo() {
|
|
resize();
|
|
drawBackground(ctx, viewport.w, viewport.h, layout, 1, plainBackground, transparentBackground);
|
|
if (layout) drawSharpLogo(ctx, displayLogo, layout, 1);
|
|
updateProgressChrome(
|
|
percentEl,
|
|
barFill,
|
|
1,
|
|
getExternalProgress,
|
|
loaderTheme,
|
|
animationEndMs(durationMs),
|
|
durationMs,
|
|
);
|
|
finishIfDone(playbackEndMs);
|
|
}
|
|
|
|
function drawAnimatedFrame(t, elapsedMs) {
|
|
const shimmerMs = shimmerDurationMs();
|
|
const shimmerStartMs = wave2EndMs(durationMs);
|
|
|
|
withLogoTransform(ctx, layout, t, () => {
|
|
if (t >= WAVE2_END) {
|
|
drawSharpLogo(ctx, displayLogo, layout, 1);
|
|
if (
|
|
elapsedMs >= shimmerStartMs
|
|
&& elapsedMs < shimmerStartMs + shimmerMs
|
|
) {
|
|
drawShimmer(
|
|
ctx,
|
|
layout,
|
|
displayLogo,
|
|
(elapsedMs - shimmerStartMs) / shimmerMs,
|
|
loaderTheme,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
drawWaves(ctx, layout, initialCell, logoCells, t, displayLogo, logoWidth, logoHeight);
|
|
});
|
|
}
|
|
|
|
function drawStaticIntroFrame(t) {
|
|
drawBackground(ctx, viewport.w, viewport.h, layout, t, plainBackground, transparentBackground);
|
|
if (!layout) return;
|
|
const alpha = easeOutQuad(Math.min(1, t / 0.82));
|
|
drawSharpLogo(ctx, displayLogo, layout, alpha);
|
|
}
|
|
|
|
function cycleElapsedMs(elapsed) {
|
|
if (!loop) return elapsed;
|
|
return elapsed % durationMs;
|
|
}
|
|
|
|
function drawFrame(now) {
|
|
if (disposed) return;
|
|
|
|
const elapsed = now - start;
|
|
const cycleMs = cycleElapsedMs(elapsed);
|
|
const t = loop
|
|
? cycleMs / durationMs
|
|
: Math.min(1, elapsed / durationMs);
|
|
const shimmerMs = shimmerDurationMs();
|
|
const shimmerStartMs = wave2EndMs(durationMs);
|
|
const inShimmer =
|
|
cycleMs >= shimmerStartMs && cycleMs < shimmerStartMs + shimmerMs;
|
|
|
|
updateProgressChrome(
|
|
percentEl,
|
|
barFill,
|
|
t,
|
|
getExternalProgress,
|
|
loaderTheme,
|
|
cycleMs,
|
|
durationMs,
|
|
);
|
|
|
|
if (staticLogo) {
|
|
drawStaticIntroFrame(t);
|
|
finishIfDone(elapsed);
|
|
if (loop || elapsed < playbackEndMs) {
|
|
rafId = requestAnimationFrame(drawFrame);
|
|
}
|
|
return;
|
|
}
|
|
|
|
drawBackground(ctx, viewport.w, viewport.h, layout, t, plainBackground, transparentBackground);
|
|
if (!layout) return;
|
|
|
|
if (t < HOLD_END || inShimmer) {
|
|
drawAnimatedFrame(t, cycleMs);
|
|
} else if (loop) {
|
|
withLogoTransform(ctx, layout, t, () => {
|
|
const fade = 1 - easeOutQuad((t - HOLD_END) / (1 - HOLD_END));
|
|
drawSharpLogo(ctx, displayLogo, layout, fade);
|
|
});
|
|
} else {
|
|
withLogoTransform(ctx, layout, t, () => {
|
|
drawSharpLogo(ctx, displayLogo, layout, 1);
|
|
});
|
|
}
|
|
|
|
finishIfDone(elapsed);
|
|
|
|
if (loop || elapsed < playbackEndMs) {
|
|
rafId = requestAnimationFrame(drawFrame);
|
|
}
|
|
}
|
|
|
|
resize();
|
|
globalThis.addEventListener("resize", resize);
|
|
|
|
if (prefersReducedMotion() || staticLogo) {
|
|
if (staticLogo) {
|
|
rafId = requestAnimationFrame(drawFrame);
|
|
} else {
|
|
drawStaticLogo();
|
|
}
|
|
} else {
|
|
rafId = requestAnimationFrame(drawFrame);
|
|
}
|
|
|
|
return function disposeWespLogoLoader() {
|
|
if (disposed) return;
|
|
disposed = true;
|
|
cancelAnimationFrame(rafId);
|
|
globalThis.removeEventListener("resize", resize);
|
|
root.classList.add("wesp-logo-loader--exiting");
|
|
window.setTimeout(() => {
|
|
root.classList.add("wesp-logo-loader--hidden");
|
|
window.setTimeout(() => {
|
|
if (root.isConnected) root.remove();
|
|
}, 480);
|
|
}, 520);
|
|
};
|
|
}
|
|
|
|
export async function showWespLogoLoader(options = {}) {
|
|
const root = document.createElement("div");
|
|
root.id = options.id ?? "wespBootLoader";
|
|
root.className = "wesp-logo-loader";
|
|
root.setAttribute("aria-busy", "true");
|
|
root.setAttribute("aria-label", options.label ?? "Загрузка");
|
|
document.body.appendChild(root);
|
|
return mountWespLogoLoader(root, options);
|
|
}
|