mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-08-28 09:11:06 +00:00
feat: add performant webgl loading screens
This commit is contained in:
Binary file not shown.
@@ -19,7 +19,7 @@ import { AddBetterSEQTAElements } from "@/seqta/ui/AddBetterSEQTAElements";
|
||||
import { updateAllColors } from "@/seqta/ui/colors/Manager";
|
||||
import { applySelectedFont } from "@/seqta/ui/fonts/Manager";
|
||||
import { verboseInfo, verboseLog } from "@/utils/verboseLog";
|
||||
import loading from "@/seqta/ui/Loading";
|
||||
import loading, { stopLoadingAnimation } from "@/seqta/ui/Loading";
|
||||
import { SendNewsPage } from "@/seqta/utils/SendNewsPage";
|
||||
import { getEngageRoutePage } from "@/seqta/utils/engageRoute";
|
||||
import {
|
||||
@@ -78,6 +78,7 @@ let engageHashListenerAttached = false;
|
||||
|
||||
export async function finishLoad() {
|
||||
if (betterSeqtaFinishLoadDone) return;
|
||||
|
||||
betterSeqtaFinishLoadDone = true;
|
||||
|
||||
try {
|
||||
@@ -93,6 +94,7 @@ export async function finishLoad() {
|
||||
|
||||
const loadingbk = document.getElementById("loading");
|
||||
loadingbk?.classList.add("closeLoading");
|
||||
stopLoadingAnimation();
|
||||
await delay(501);
|
||||
loadingbk?.remove();
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { getEngageRoutePage } from "@/seqta/utils/engageRoute";
|
||||
import {
|
||||
loadEngageHomePage,
|
||||
updateEngageHomeMenuActive,
|
||||
} from "@/seqta/utils/Loaders/LoadEngageHomePage";
|
||||
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||
|
||||
let engageHashListenerAttached = false;
|
||||
|
||||
export function attachEngageHashListenerIfNeeded(): void {
|
||||
if (!isSeqtaEngageExperience() || engageHashListenerAttached) return;
|
||||
engageHashListenerAttached = true;
|
||||
window.addEventListener("hashchange", () => {
|
||||
if (getEngageRoutePage() === "home") {
|
||||
void loadEngageHomePage();
|
||||
} else {
|
||||
updateEngageHomeMenuActive(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
+203
-67
@@ -1,102 +1,238 @@
|
||||
import browser from "webextension-polyfill";
|
||||
import stringToHTML from "@/seqta/utils/stringToHTML";
|
||||
import loadingSpinner from "./loading-spinner.html?raw";
|
||||
import loadingOverlay from "./loading-overlay.html?raw";
|
||||
import { startLoadingCanvas } from "./loadingCanvas";
|
||||
import {
|
||||
pickLoadingVariant,
|
||||
type LoadingVariant,
|
||||
} from "./loadingVariants";
|
||||
|
||||
export function AppendLoadingSymbol(givenID: any, position: any) {
|
||||
let loadingsymbol = stringToHTML(/* html */ `
|
||||
<div id="${givenID}">
|
||||
${loadingSpinner}
|
||||
</div>`).firstChild;
|
||||
let stopCanvas: (() => void) | null = null;
|
||||
|
||||
document.querySelector(position).appendChild(loadingsymbol);
|
||||
}
|
||||
|
||||
export default function loading() {
|
||||
let loadinghtml = stringToHTML(/* html */ `
|
||||
<div class="bkloading" id="loading">
|
||||
<style>
|
||||
const loadingStyles = /* css */ `
|
||||
.bkloading {
|
||||
transition: color 1ms linear, opacity 1s ease-in-out;
|
||||
background-color: rgb(229, 231, 235);
|
||||
color: black;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
opacity: 1;
|
||||
transition: 1s;
|
||||
height: 100%;
|
||||
z-index: 1000000;
|
||||
--bk-line-color: rgba(255, 255, 255, 0.08);
|
||||
--bk-line-accent: rgba(255, 255, 255, 0.15);
|
||||
--bk-line-blue: rgba(96, 165, 250, 0.42);
|
||||
--bk-grid-color: rgba(255, 255, 255, 0.028);
|
||||
--bk-spin-outer: 1s;
|
||||
--bk-spin-inner: 3s;
|
||||
--bk-spin-small: 3s;
|
||||
--bk-stage-name: bkloading-stage-in;
|
||||
--bk-stage-duration: 0.85s;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
inset: 0;
|
||||
z-index: 1000000;
|
||||
overflow: hidden;
|
||||
contain: strict;
|
||||
color: #f4f4f5;
|
||||
background:
|
||||
radial-gradient(ellipse 70% 45% at 50% 95%, rgba(37, 99, 235, 0.12), transparent 62%),
|
||||
linear-gradient(180deg, #010101 0%, #040404 50%, #080808 100%);
|
||||
opacity: 1;
|
||||
transition: opacity 0.85s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.closeLoading {
|
||||
.bkloading.closeLoading {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dark .bkloading {
|
||||
background-color: rgb(26, 26, 26);
|
||||
color: white;
|
||||
.bkloading__canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.svg {
|
||||
transform-origin: center;
|
||||
.bkloading__vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(ellipse at center, transparent 32%, rgba(0, 0, 0, 0.75) 100%);
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.bkloading__content {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bkloading__logo-stage,
|
||||
.bkloading-inline {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bkloading__logo-stage {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
animation-name: var(--bk-stage-name);
|
||||
animation-duration: var(--bk-stage-duration);
|
||||
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
.bkloading-inline {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.bkloading__logo-stage .svg,
|
||||
.bkloading-inline .svg {
|
||||
transform-origin: center center;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
backface-visibility: hidden;
|
||||
will-change: transform;
|
||||
}
|
||||
.logo {
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
.bkloading__logo-stage .logo,
|
||||
.bkloading-inline .logo {
|
||||
transform: translate3d(-50%, -50%, 0);
|
||||
}
|
||||
.big-circle {
|
||||
|
||||
.bkloading__logo-stage .logo {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.bkloading__logo-stage .big-circle {
|
||||
margin: -90px;
|
||||
animation: bkloading-spin var(--bk-spin-inner) linear infinite;
|
||||
}
|
||||
|
||||
.bkloading__logo-stage .small-circle {
|
||||
margin: -67px;
|
||||
animation: bkloading-spin var(--bk-spin-small) linear infinite;
|
||||
}
|
||||
|
||||
.bkloading__logo-stage .outer-circle {
|
||||
margin: -110px;
|
||||
animation: bkloading-spin-reverse var(--bk-spin-outer) linear infinite;
|
||||
}
|
||||
|
||||
.bkloading-inline .big-circle {
|
||||
margin: -88px;
|
||||
will-change: transform;
|
||||
animation-timing-function: ease;
|
||||
animation: spin 3s linear infinite;
|
||||
-moz-animation: spin 3s linear infinite;
|
||||
animation: bkloading-spin 3s linear infinite;
|
||||
}
|
||||
.small-circle {
|
||||
|
||||
.bkloading-inline .small-circle {
|
||||
margin: -66px;
|
||||
will-change: transform;
|
||||
animation-timing-function: ease;
|
||||
animation: spin 3s linear infinite;
|
||||
-moz-animation: spin 3s linear infinite;
|
||||
animation: bkloading-spin-reverse 3s linear infinite;
|
||||
}
|
||||
.outer-circle {
|
||||
|
||||
.bkloading-inline .outer-circle {
|
||||
margin: -108px;
|
||||
will-change: transform;
|
||||
animation-direction: alternate-reverse;
|
||||
animation: spinback 1s linear infinite;
|
||||
-moz-animation: spinback 1s linear infinite;
|
||||
animation: bkloading-spin 4.5s linear infinite;
|
||||
}
|
||||
@-moz-keyframes spin {
|
||||
100% {
|
||||
-moz-transform: rotate(360deg);
|
||||
|
||||
.bkloading__version {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 10px;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.02em;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes bkloading-stage-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 12px, 0) scale(0.94);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
@-webkit-keyframes spin {
|
||||
100% {
|
||||
-webkit-transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@keyframes spin {
|
||||
100% {
|
||||
-webkit-transform: rotate(360deg);
|
||||
|
||||
@keyframes bkloading-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@keyframes spinback {
|
||||
100% {
|
||||
-webkit-transform: rotate(-360deg);
|
||||
|
||||
@keyframes bkloading-spin-reverse {
|
||||
to {
|
||||
transform: rotate(-360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
${loadingSpinner}
|
||||
<div style="position: absolute;bottom: 0;right: 0;padding: 10px;color: #4f4f4f;text-anchor: middle;font-size: 20px;">v${
|
||||
browser.runtime.getManifest().version
|
||||
}</div></div>`);
|
||||
var html = document.getElementsByTagName("html")[0];
|
||||
html.append(loadinghtml.firstChild!);
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bkloading__logo-stage .big-circle,
|
||||
.bkloading__logo-stage .small-circle,
|
||||
.bkloading__logo-stage .outer-circle,
|
||||
.bkloading-inline .big-circle,
|
||||
.bkloading-inline .small-circle,
|
||||
.bkloading-inline .outer-circle {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function overlayWithSpinner(): string {
|
||||
return loadingOverlay.replace(
|
||||
"<div class=\"bkloading__logo-stage\"></div>",
|
||||
`<div class="bkloading__logo-stage">${loadingSpinner}</div>`,
|
||||
);
|
||||
}
|
||||
|
||||
function applyVariantTheme(root: HTMLElement, variant: LoadingVariant) {
|
||||
const { theme } = variant;
|
||||
root.dataset.variant = variant.id;
|
||||
root.style.background = theme.background;
|
||||
root.style.setProperty("--bk-spin-outer", theme.spinOuter);
|
||||
root.style.setProperty("--bk-spin-inner", theme.spinInner);
|
||||
root.style.setProperty("--bk-spin-small", theme.spinSmall);
|
||||
root.style.setProperty("--bk-stage-name", theme.stageName);
|
||||
root.style.setProperty("--bk-stage-duration", theme.stageDuration);
|
||||
|
||||
const vignette = root.querySelector(".bkloading__vignette") as HTMLElement | null;
|
||||
if (vignette) vignette.style.opacity = String(theme.vignetteOpacity);
|
||||
}
|
||||
|
||||
function startCanvasForRoot(root: HTMLElement, variant: LoadingVariant) {
|
||||
stopCanvas?.();
|
||||
const canvas = root.querySelector(".bkloading__canvas") as HTMLCanvasElement | null;
|
||||
stopCanvas = canvas ? startLoadingCanvas(canvas, root, variant) : null;
|
||||
}
|
||||
|
||||
export function AppendLoadingSymbol(givenID: string, position: string) {
|
||||
const loadingsymbol = stringToHTML(/* html */ `
|
||||
<div id="${givenID}" class="bkloading-inline">
|
||||
${loadingSpinner}
|
||||
</div>`).firstChild;
|
||||
|
||||
document.querySelector(position)?.appendChild(loadingsymbol!);
|
||||
}
|
||||
|
||||
export function stopLoadingAnimation() {
|
||||
stopCanvas?.();
|
||||
stopCanvas = null;
|
||||
}
|
||||
|
||||
export default function loading() {
|
||||
stopLoadingAnimation();
|
||||
|
||||
const variant = pickLoadingVariant();
|
||||
|
||||
const loadinghtml = stringToHTML(/* html */ `
|
||||
<div class="bkloading" id="loading" data-variant="${variant.id}">
|
||||
<style>${loadingStyles}</style>
|
||||
${overlayWithSpinner()}
|
||||
<div class="bkloading__version">v${browser.runtime.getManifest().version}</div>
|
||||
</div>`);
|
||||
|
||||
const root = loadinghtml.firstChild as HTMLElement;
|
||||
document.documentElement.append(root);
|
||||
|
||||
applyVariantTheme(root, variant);
|
||||
startCanvasForRoot(root, variant);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<canvas class="bkloading__canvas" aria-hidden="true"></canvas>
|
||||
<div class="bkloading__vignette" aria-hidden="true"></div>
|
||||
<div class="bkloading__content">
|
||||
<div class="bkloading__logo-stage"></div>
|
||||
</div>
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,364 @@
|
||||
import { pickLoadingVariant, variantRevealMs } from "./loadingVariants";
|
||||
|
||||
const smooth = (t: number) => t * t * (3 - 2 * t);
|
||||
|
||||
type SoftBlob = {
|
||||
cx: number;
|
||||
cy: number;
|
||||
rx: number;
|
||||
ry: number;
|
||||
rgb: string;
|
||||
phase: number;
|
||||
speed: number;
|
||||
amp?: number;
|
||||
rot?: number;
|
||||
drift?: number;
|
||||
};
|
||||
|
||||
const DIFFUSE: SoftBlob[] = [
|
||||
{ cx: 0.26, cy: 1.14, rx: 0.4, ry: 0.34, rgb: "37, 99, 235", phase: 0, speed: 1, amp: 0.26, rot: 0.38 },
|
||||
{ cx: 0.58, cy: 1.2, rx: 0.44, ry: 0.38, rgb: "59, 130, 246", phase: 2.2, speed: 0.82, amp: 0.3, rot: -0.32 },
|
||||
{ cx: 0.46, cy: 1.1, rx: 0.36, ry: 0.28, rgb: "29, 78, 216", phase: 4.4, speed: 1.12, amp: 0.22, rot: 0.45 },
|
||||
{ cx: 0.8, cy: 1.08, rx: 0.3, ry: 0.3, rgb: "96, 165, 250", phase: 1.5, speed: 0.74, amp: 0.24, rot: -0.4 },
|
||||
{ cx: 0.12, cy: 1.12, rx: 0.32, ry: 0.32, rgb: "37, 99, 235", phase: 3.6, speed: 0.96, amp: 0.25, rot: 0.34 },
|
||||
{ cx: 0.68, cy: 1.06, rx: 0.26, ry: 0.24, rgb: "14, 116, 244", phase: 5.1, speed: 0.88, amp: 0.18, rot: -0.28 },
|
||||
];
|
||||
|
||||
const TIDE: SoftBlob[] = [
|
||||
{ cx: 0.5, cy: 0.68, rx: 0.72, ry: 0.11, rgb: "37, 99, 235", phase: 0, speed: 0.42, drift: 0.08 },
|
||||
{ cx: 0.32, cy: 0.8, rx: 0.58, ry: 0.09, rgb: "59, 130, 246", phase: 2.4, speed: 0.36, drift: 0.06 },
|
||||
{ cx: 0.68, cy: 0.86, rx: 0.64, ry: 0.12, rgb: "29, 78, 216", phase: 4.6, speed: 0.4, drift: 0.07 },
|
||||
{ cx: 0.5, cy: 0.96, rx: 0.78, ry: 0.1, rgb: "96, 165, 250", phase: 1.1, speed: 0.33, drift: 0.05 },
|
||||
];
|
||||
|
||||
const GLOBE_STAGGER_MS = 720;
|
||||
const GLOBE_DOT_DUR_MS = 260;
|
||||
const GLOBE_REVEAL_MS = GLOBE_STAGGER_MS + GLOBE_DOT_DUR_MS;
|
||||
|
||||
const GLOBE_DOTS = (() => {
|
||||
const n = 360;
|
||||
const golden = Math.PI * (3 - Math.sqrt(5));
|
||||
return Array.from({ length: n }, (_, i) => {
|
||||
const y = 1 - (2 * i) / (n - 1);
|
||||
const ring = Math.sqrt(Math.max(0, 1 - y * y));
|
||||
const theta = golden * i;
|
||||
const x = Math.cos(theta) * ring;
|
||||
const z = Math.sin(theta) * ring;
|
||||
return { x, y, z, theta, rank: (x + 1 - y + 2) / 4 };
|
||||
});
|
||||
})();
|
||||
|
||||
function stagger(
|
||||
elapsed: number,
|
||||
delay: number,
|
||||
dur: number,
|
||||
fade: boolean,
|
||||
reduced: boolean,
|
||||
): number {
|
||||
if (reduced) return 1;
|
||||
if (elapsed < delay) return fade ? 1 : 0;
|
||||
const t = smooth(Math.min(1, (elapsed - delay) / dur));
|
||||
return fade ? 1 - t : t;
|
||||
}
|
||||
|
||||
function cycle(
|
||||
elapsed: number,
|
||||
revealMs: number,
|
||||
holdMs: number,
|
||||
): { t: number; fade: boolean; ambient: number } {
|
||||
const len = revealMs * 2 + holdMs;
|
||||
const tick = elapsed % len;
|
||||
if (tick < revealMs) {
|
||||
return { t: tick, fade: false, ambient: smooth(tick / revealMs) };
|
||||
}
|
||||
if (tick < revealMs + holdMs) {
|
||||
return { t: revealMs, fade: false, ambient: 1 };
|
||||
}
|
||||
const fadeT = tick - revealMs - holdMs;
|
||||
return { t: fadeT, fade: true, ambient: smooth((len - tick) / revealMs) };
|
||||
}
|
||||
|
||||
function cssVar(root: HTMLElement, name: string, fallback: string): string {
|
||||
return getComputedStyle(root).getPropertyValue(name).trim() || fallback;
|
||||
}
|
||||
|
||||
function drawGrid(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
color: string,
|
||||
w: number,
|
||||
h: number,
|
||||
a: number,
|
||||
cols: number,
|
||||
rows: number,
|
||||
) {
|
||||
if (cols <= 0 || rows <= 0) return;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = color;
|
||||
ctx.globalAlpha = a;
|
||||
for (let c = 1; c < cols; c++) {
|
||||
const x = (w / cols) * c;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, h);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let r = 1; r < rows; r++) {
|
||||
const y = (h / rows) * r;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(w, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawSoftBlobs(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
w: number,
|
||||
h: number,
|
||||
elapsed: number,
|
||||
reduced: boolean,
|
||||
blobs: SoftBlob[],
|
||||
blur: number,
|
||||
fadeMs: number,
|
||||
tide: boolean,
|
||||
) {
|
||||
const time = reduced ? 0 : elapsed * 0.001;
|
||||
const fadeIn = reduced ? 1 : smooth(Math.min(1, elapsed / fadeMs));
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = fadeIn;
|
||||
ctx.filter = `blur(${blur}px)`;
|
||||
|
||||
for (const b of blobs) {
|
||||
const t = time * b.speed + b.phase;
|
||||
let x: number;
|
||||
let y: number;
|
||||
let rx: number;
|
||||
let ry: number;
|
||||
let a0: number;
|
||||
let a1: number;
|
||||
|
||||
if (tide) {
|
||||
const breathe = 1 + Math.sin(t * 1.1) * 0.14;
|
||||
x = b.cx * w + Math.sin(t * 0.65) * w * (b.drift ?? 0);
|
||||
y = b.cy * h + Math.sin(t * 0.48 + b.phase) * h * 0.025;
|
||||
rx = b.rx * w;
|
||||
ry = b.ry * h * breathe;
|
||||
a0 = 0.38;
|
||||
a1 = 0.14;
|
||||
} else {
|
||||
const amp = b.amp ?? 0.2;
|
||||
const scale = 1 + Math.sin(t * 1.35) * amp;
|
||||
const stretch = 1 + Math.cos(t * 0.88) * amp * 0.55;
|
||||
x = b.cx * w + Math.sin(t * 0.52) * w * 0.04;
|
||||
y = b.cy * h + Math.sin(t * 0.41) * h * 0.01;
|
||||
rx = b.rx * w * scale;
|
||||
ry = b.ry * h * stretch;
|
||||
a0 = 0.5;
|
||||
a1 = 0.18;
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.rotate(t * (b.rot ?? 0));
|
||||
x = 0;
|
||||
y = 0;
|
||||
}
|
||||
|
||||
const radius = Math.max(rx, ry);
|
||||
const grad = ctx.createRadialGradient(x, y, 0, x, y, radius);
|
||||
grad.addColorStop(0, `rgba(${b.rgb}, ${a0})`);
|
||||
grad.addColorStop(tide ? 0.45 : 0.4, `rgba(${b.rgb}, ${a1})`);
|
||||
grad.addColorStop(1, `rgba(${b.rgb}, 0)`);
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(x - rx, y - ry, rx * 2, ry * 2);
|
||||
if (!tide) ctx.restore();
|
||||
}
|
||||
|
||||
ctx.filter = "none";
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawDotGlobe(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
w: number,
|
||||
h: number,
|
||||
elapsed: number,
|
||||
reduced: boolean,
|
||||
phaseElapsed: number,
|
||||
fade: boolean,
|
||||
ambient: number,
|
||||
) {
|
||||
const time = reduced ? 0 : elapsed * 0.001;
|
||||
const cx = w * 0.5;
|
||||
const cy = h * 0.5;
|
||||
const radius = Math.min(w, h) * 0.4;
|
||||
const yaw = time * 0.38;
|
||||
const pitch = Math.sin(time * 0.22) * 0.18;
|
||||
const cosYaw = Math.cos(yaw);
|
||||
const sinYaw = Math.sin(yaw);
|
||||
const cosPitch = Math.cos(pitch);
|
||||
const sinPitch = Math.sin(pitch);
|
||||
const lx = Math.cos(time * 0.5);
|
||||
const ly = Math.sin(time * 0.32) * 0.45 + 0.35;
|
||||
const lz = Math.sin(time * 0.5);
|
||||
const lLen = Math.hypot(lx, ly, lz) || 1;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = ambient;
|
||||
const glow = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius * 1.15);
|
||||
glow.addColorStop(0, "rgba(37, 99, 235, 0.1)");
|
||||
glow.addColorStop(0.55, "rgba(37, 99, 235, 0.03)");
|
||||
glow.addColorStop(1, "rgba(37, 99, 235, 0)");
|
||||
ctx.fillStyle = glow;
|
||||
ctx.fillRect(cx - radius * 1.2, cy - radius * 1.2, radius * 2.4, radius * 2.4);
|
||||
|
||||
const dots: {
|
||||
px: number;
|
||||
py: number;
|
||||
z: number;
|
||||
theta: number;
|
||||
nx: number;
|
||||
ny: number;
|
||||
nz: number;
|
||||
p: number;
|
||||
}[] = [];
|
||||
|
||||
for (const dot of GLOBE_DOTS) {
|
||||
const p = stagger(phaseElapsed, dot.rank * GLOBE_STAGGER_MS, GLOBE_DOT_DUR_MS, fade, reduced);
|
||||
if (p <= 0) continue;
|
||||
|
||||
const x = cosYaw * dot.x + sinYaw * dot.z;
|
||||
const z0 = -sinYaw * dot.x + cosYaw * dot.z;
|
||||
const y = cosPitch * dot.y - sinPitch * z0;
|
||||
const z = sinPitch * dot.y + cosPitch * z0;
|
||||
|
||||
dots.push({
|
||||
px: cx + x * radius,
|
||||
py: cy - y * radius,
|
||||
z,
|
||||
theta: dot.theta,
|
||||
nx: x,
|
||||
ny: y,
|
||||
nz: z,
|
||||
p,
|
||||
});
|
||||
}
|
||||
|
||||
dots.sort((a, b) => a.z - b.z);
|
||||
|
||||
for (const dot of dots) {
|
||||
const nLen = Math.hypot(dot.nx, dot.ny, dot.nz) || 1;
|
||||
const facing = (dot.nx * lx + dot.ny * ly + dot.nz * lz) / (lLen * nLen);
|
||||
const wave = Math.sin(dot.theta * 2.8 - time * 1.6) * 0.28;
|
||||
const glowWave = Math.sin(dot.theta * 1.4 + time * 2.1) * 0.15;
|
||||
const brightness = Math.min(1, 0.18 + Math.max(0, facing) * 0.62 + wave + glowWave);
|
||||
const depth = (dot.z + 1) * 0.5;
|
||||
const size = (1.2 + depth * 2.2) * dot.p;
|
||||
const alpha = (0.12 + brightness * 0.75) * dot.p;
|
||||
|
||||
ctx.fillStyle =
|
||||
brightness > 0.52
|
||||
? `rgba(96, 165, 250, ${alpha})`
|
||||
: `rgba(255, 255, 255, ${0.08 + brightness * 0.22 * dot.p})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(dot.px, dot.py, size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export function startLoadingCanvas(
|
||||
canvas: HTMLCanvasElement,
|
||||
root: HTMLElement,
|
||||
variant = pickLoadingVariant(),
|
||||
): () => void {
|
||||
const ctx = canvas.getContext("2d", { alpha: true, desynchronized: true });
|
||||
if (!ctx) return () => undefined;
|
||||
|
||||
const visual = variant.visual ?? "lines";
|
||||
const { lines, holdMs, grid } = variant;
|
||||
const revealMs = visual === "globe" ? GLOBE_REVEAL_MS : variantRevealMs(variant);
|
||||
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const start = performance.now();
|
||||
let raf = 0;
|
||||
let w = 0;
|
||||
let h = 0;
|
||||
|
||||
const resize = () => {
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
w = canvas.clientWidth;
|
||||
h = canvas.clientHeight;
|
||||
canvas.width = Math.max(1, Math.floor(w * dpr));
|
||||
canvas.height = Math.max(1, Math.floor(h * dpr));
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
};
|
||||
|
||||
const ro = new ResizeObserver(resize);
|
||||
ro.observe(canvas);
|
||||
resize();
|
||||
|
||||
const frame = (now: number) => {
|
||||
if (!canvas.isConnected) return;
|
||||
|
||||
const elapsed = now - start;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
if (visual === "globe") {
|
||||
const { t, fade, ambient } = cycle(elapsed, revealMs, holdMs);
|
||||
drawDotGlobe(ctx, w, h, elapsed, reduced, t, fade, reduced ? 1 : ambient);
|
||||
} else if (visual === "blobs") {
|
||||
const tide = variant.blobStyle === "tide";
|
||||
drawSoftBlobs(
|
||||
ctx,
|
||||
w,
|
||||
h,
|
||||
elapsed,
|
||||
reduced,
|
||||
tide ? TIDE : DIFFUSE,
|
||||
tide ? 58 : 52,
|
||||
tide ? 1800 : 1600,
|
||||
tide,
|
||||
);
|
||||
} else {
|
||||
const { t, fade } = cycle(elapsed, revealMs, holdMs);
|
||||
const gridA = reduced ? 1 : smooth(Math.min(1, elapsed / 1400)) * 0.7;
|
||||
|
||||
drawGrid(
|
||||
ctx,
|
||||
cssVar(root, "--bk-grid-color", "rgba(255,255,255,0.03)"),
|
||||
w,
|
||||
h,
|
||||
gridA,
|
||||
grid.cols,
|
||||
grid.rows,
|
||||
);
|
||||
|
||||
const lineColor = cssVar(root, "--bk-line-color", "rgba(255,255,255,0.08)");
|
||||
const lineAccent = cssVar(root, "--bk-line-accent", "rgba(255,255,255,0.15)");
|
||||
const lineBlue = cssVar(root, "--bk-line-blue", "rgba(96,165,250,0.4)");
|
||||
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const p = stagger(t, line.delay, line.dur, fade, reduced);
|
||||
if (p <= 0) continue;
|
||||
|
||||
ctx.strokeStyle = line.blue ? lineBlue : i % 2 ? lineColor : lineAccent;
|
||||
ctx.setLineDash(line.dashed ? [8, 11] : []);
|
||||
line.stroke(ctx, p, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(frame);
|
||||
};
|
||||
|
||||
raf = requestAnimationFrame(frame);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
ro.disconnect();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
export type Line = {
|
||||
delay: number;
|
||||
dur: number;
|
||||
dashed?: boolean;
|
||||
blue?: boolean;
|
||||
stroke: (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
p: number,
|
||||
w: number,
|
||||
h: number,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export type LoadingVariant = {
|
||||
id: string;
|
||||
visual?: "lines" | "blobs" | "globe";
|
||||
blobStyle?: "diffuse" | "tide";
|
||||
holdMs: number;
|
||||
grid: { cols: number; rows: number };
|
||||
lines: Line[];
|
||||
theme: {
|
||||
background: string;
|
||||
vignetteOpacity: number;
|
||||
spinOuter: string;
|
||||
spinInner: string;
|
||||
spinSmall: string;
|
||||
stageName: string;
|
||||
stageDuration: string;
|
||||
};
|
||||
};
|
||||
|
||||
const curve = (
|
||||
sample: (t: number, w: number, h: number) => { x: number; y: number },
|
||||
): Line["stroke"] => {
|
||||
return (ctx, p, w, h) => {
|
||||
const steps = Math.max(24, Math.floor(140 * p));
|
||||
if (steps <= 1) return;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = (i / 140) * p;
|
||||
const { x, y } = sample(Math.min(1, t), w, h);
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
};
|
||||
};
|
||||
|
||||
const segment = (x1: number, y1: number, x2: number, y2: number): Line["stroke"] => {
|
||||
return (ctx, p, w, h) => {
|
||||
const ax = x1 * w;
|
||||
const ay = y1 * h;
|
||||
const bx = x2 * w;
|
||||
const by = y2 * h;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ax, ay);
|
||||
ctx.lineTo(ax + (bx - ax) * p, ay + (by - ay) * p);
|
||||
ctx.stroke();
|
||||
};
|
||||
};
|
||||
|
||||
const BASE = "linear-gradient(180deg, #010101 0%, #040404 50%, #080808 100%)";
|
||||
const STAGE = "bkloading-stage-in";
|
||||
|
||||
function theme(
|
||||
background: string,
|
||||
vignetteOpacity: number,
|
||||
spinOuter: string,
|
||||
spinInner: string,
|
||||
spinSmall: string,
|
||||
stageDuration: string,
|
||||
) {
|
||||
return {
|
||||
background,
|
||||
vignetteOpacity,
|
||||
spinOuter,
|
||||
spinInner,
|
||||
spinSmall,
|
||||
stageName: STAGE,
|
||||
stageDuration,
|
||||
};
|
||||
}
|
||||
|
||||
function canvasVariant(
|
||||
id: string,
|
||||
visual: "blobs" | "globe",
|
||||
holdMs: number,
|
||||
t: ReturnType<typeof theme>,
|
||||
blobStyle?: "diffuse" | "tide",
|
||||
): LoadingVariant {
|
||||
return {
|
||||
id,
|
||||
visual,
|
||||
blobStyle,
|
||||
holdMs,
|
||||
grid: { cols: 0, rows: 0 },
|
||||
lines: [],
|
||||
theme: t,
|
||||
};
|
||||
}
|
||||
|
||||
const VARIANTS: LoadingVariant[] = [
|
||||
{
|
||||
id: "sweep",
|
||||
holdMs: 2200,
|
||||
grid: { cols: 10, rows: 6 },
|
||||
theme: theme(
|
||||
`radial-gradient(ellipse 70% 45% at 50% 95%, rgba(37, 99, 235, 0.12), transparent 62%), ${BASE}`,
|
||||
0.88,
|
||||
"1s",
|
||||
"3s",
|
||||
"3s",
|
||||
"0.85s",
|
||||
),
|
||||
lines: [
|
||||
{
|
||||
delay: 0,
|
||||
dur: 1500,
|
||||
stroke: curve((t, w, h) => ({
|
||||
x: w * (0.06 + t * 0.78),
|
||||
y: h * (0.9 - Math.sin(t * Math.PI) * 0.55),
|
||||
})),
|
||||
},
|
||||
{
|
||||
delay: 100,
|
||||
dur: 1600,
|
||||
dashed: true,
|
||||
blue: true,
|
||||
stroke: curve((t, w, h) => {
|
||||
const a = Math.PI * 0.12 + t * Math.PI;
|
||||
return {
|
||||
x: w * 0.74 + Math.cos(a) * w * 0.34,
|
||||
y: h * 0.56 + Math.sin(a) * h * 0.42,
|
||||
};
|
||||
}),
|
||||
},
|
||||
{
|
||||
delay: 200,
|
||||
dur: 1400,
|
||||
dashed: true,
|
||||
stroke: curve((t, w, h) => {
|
||||
const a = Math.PI * 1.05 - t * Math.PI * 0.8;
|
||||
return {
|
||||
x: w * 0.28 + Math.cos(a) * w * 0.28,
|
||||
y: h * 0.34 + Math.sin(a) * h * 0.3,
|
||||
};
|
||||
}),
|
||||
},
|
||||
{ delay: 60, dur: 900, stroke: segment(0.4, 0.1, 0.4, 0.94) },
|
||||
{ delay: 160, dur: 1100, stroke: segment(0.04, 0.64, 0.96, 0.64) },
|
||||
{
|
||||
delay: 280,
|
||||
dur: 1700,
|
||||
stroke: curve((t, w, h) => ({
|
||||
x: w * (0.52 + t * 0.4),
|
||||
y: h * (0.14 + t * t * 0.7),
|
||||
})),
|
||||
},
|
||||
{
|
||||
delay: 240,
|
||||
dur: 900,
|
||||
dashed: true,
|
||||
blue: true,
|
||||
stroke: segment(0.1, 0.2, 0.5, 0.48),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cross",
|
||||
holdMs: 2000,
|
||||
grid: { cols: 12, rows: 8 },
|
||||
theme: theme(
|
||||
`radial-gradient(ellipse 40% 30% at 80% 20%, rgba(59, 130, 246, 0.08), transparent 70%), ${BASE}`,
|
||||
0.9,
|
||||
"0.85s",
|
||||
"2.6s",
|
||||
"2.6s",
|
||||
"0.75s",
|
||||
),
|
||||
lines: [
|
||||
{ delay: 0, dur: 1200, stroke: segment(0.5, 0.06, 0.5, 0.94) },
|
||||
{ delay: 80, dur: 1200, stroke: segment(0.04, 0.5, 0.96, 0.5) },
|
||||
{
|
||||
delay: 160,
|
||||
dur: 1000,
|
||||
dashed: true,
|
||||
blue: true,
|
||||
stroke: segment(0.08, 0.12, 0.92, 0.88),
|
||||
},
|
||||
{
|
||||
delay: 240,
|
||||
dur: 1000,
|
||||
dashed: true,
|
||||
stroke: segment(0.92, 0.12, 0.08, 0.88),
|
||||
},
|
||||
{ delay: 320, dur: 900, stroke: segment(0.5, 0.06, 0.72, 0.28) },
|
||||
{ delay: 400, dur: 900, stroke: segment(0.5, 0.94, 0.28, 0.72) },
|
||||
],
|
||||
},
|
||||
canvasVariant(
|
||||
"diffuse",
|
||||
"blobs",
|
||||
0,
|
||||
theme(BASE, 0.78, "1.3s", "3.8s", "3.8s", "0.9s"),
|
||||
"diffuse",
|
||||
),
|
||||
canvasVariant(
|
||||
"tide",
|
||||
"blobs",
|
||||
0,
|
||||
theme(
|
||||
`radial-gradient(ellipse 85% 45% at 50% 100%, rgba(37, 99, 235, 0.09), transparent 72%), ${BASE}`,
|
||||
0.8,
|
||||
"1.5s",
|
||||
"4.2s",
|
||||
"4.2s",
|
||||
"1s",
|
||||
),
|
||||
"tide",
|
||||
),
|
||||
canvasVariant(
|
||||
"globe",
|
||||
"globe",
|
||||
2500,
|
||||
theme(
|
||||
`radial-gradient(ellipse 55% 50% at 50% 48%, rgba(37, 99, 235, 0.1), transparent 70%), ${BASE}`,
|
||||
0.86,
|
||||
"1.2s",
|
||||
"3.5s",
|
||||
"3.5s",
|
||||
"0.9s",
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
export function listLoadingVariants(): LoadingVariant[] {
|
||||
return VARIANTS;
|
||||
}
|
||||
|
||||
export function getLoadingVariant(id: string): LoadingVariant | undefined {
|
||||
return VARIANTS.find((v) => v.id === id);
|
||||
}
|
||||
|
||||
export function pickLoadingVariant(): LoadingVariant {
|
||||
return VARIANTS[Math.floor(Math.random() * VARIANTS.length)];
|
||||
}
|
||||
|
||||
export function variantRevealMs(variant: LoadingVariant): number {
|
||||
if (variant.lines.length === 0) return 0;
|
||||
return Math.max(...variant.lines.map((l) => l.delay + l.dur));
|
||||
}
|
||||
Reference in New Issue
Block a user