From 07b29c8d312a774f4bfb622555dd14d40103798d Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Tue, 18 Aug 2026 18:47:12 +0930 Subject: [PATCH] feat: add performant webgl loading screens --- build-log.txt | Bin 0 -> 37094 bytes src/plugins/monofile.ts | 4 +- src/seqta/bootstrap/engageHashListener.ts | 20 ++ src/seqta/ui/Loading.ts | 312 +++++++++++++------ src/seqta/ui/loading-overlay.html | 5 + src/seqta/ui/loading-spinner.html | 2 +- src/seqta/ui/loadingCanvas.ts | 364 ++++++++++++++++++++++ src/seqta/ui/loadingVariants.ts | 251 +++++++++++++++ 8 files changed, 868 insertions(+), 90 deletions(-) create mode 100644 build-log.txt create mode 100644 src/seqta/bootstrap/engageHashListener.ts create mode 100644 src/seqta/ui/loading-overlay.html create mode 100644 src/seqta/ui/loadingCanvas.ts create mode 100644 src/seqta/ui/loadingVariants.ts diff --git a/build-log.txt b/build-log.txt new file mode 100644 index 0000000000000000000000000000000000000000..3e0cfb75494c2f311676d2765851bbd7f7e664cf GIT binary patch literal 37094 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 literal 0 HcmV?d00001 diff --git a/src/plugins/monofile.ts b/src/plugins/monofile.ts index 0a6af2de..c4fa93eb 100644 --- a/src/plugins/monofile.ts +++ b/src/plugins/monofile.ts @@ -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) { diff --git a/src/seqta/bootstrap/engageHashListener.ts b/src/seqta/bootstrap/engageHashListener.ts new file mode 100644 index 00000000..59893d2d --- /dev/null +++ b/src/seqta/bootstrap/engageHashListener.ts @@ -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); + } + }); +} diff --git a/src/seqta/ui/Loading.ts b/src/seqta/ui/Loading.ts index a6fd3d2e..41030244 100644 --- a/src/seqta/ui/Loading.ts +++ b/src/seqta/ui/Loading.ts @@ -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 */ ` -
+let stopCanvas: (() => void) | null = null; + +const loadingStyles = /* css */ ` + .bkloading { + --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; + 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); + } + + .bkloading.closeLoading { + opacity: 0; + pointer-events: none; + } + + .bkloading__canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + } + + .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; + } + + .bkloading__logo-stage .logo, + .bkloading-inline .logo { + transform: translate3d(-50%, -50%, 0); + } + + .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; + animation: bkloading-spin 3s linear infinite; + } + + .bkloading-inline .small-circle { + margin: -66px; + animation: bkloading-spin-reverse 3s linear infinite; + } + + .bkloading-inline .outer-circle { + margin: -108px; + animation: bkloading-spin 4.5s linear infinite; + } + + .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); + } + } + + @keyframes bkloading-spin { + to { + transform: rotate(360deg); + } + } + + @keyframes bkloading-spin-reverse { + to { + transform: rotate(-360deg); + } + } + + @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( + "
", + `
${loadingSpinner}
`, + ); +} + +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 */ ` +
${loadingSpinner}
`).firstChild; - document.querySelector(position).appendChild(loadingsymbol); + document.querySelector(position)?.appendChild(loadingsymbol!); +} + +export function stopLoadingAnimation() { + stopCanvas?.(); + stopCanvas = null; } export default function loading() { - let loadinghtml = stringToHTML(/* html */ ` -
- + ${overlayWithSpinner()} +
v${browser.runtime.getManifest().version}
+
`); - .svg { - transform-origin: center; - position: absolute; - top: 50%; - left: 50%; - will-change: transform; - } - .logo { - transform: translate(-50%, -50%); - } - .big-circle { - margin: -88px; - will-change: transform; - animation-timing-function: ease; - animation: spin 3s linear infinite; - -moz-animation: spin 3s linear infinite; - } - .small-circle { - margin: -66px; - will-change: transform; - animation-timing-function: ease; - animation: spin 3s linear infinite; - -moz-animation: spin 3s linear infinite; - } - .outer-circle { - margin: -108px; - will-change: transform; - animation-direction: alternate-reverse; - animation: spinback 1s linear infinite; - -moz-animation: spinback 1s linear infinite; - } - @-moz-keyframes spin { - 100% { - -moz-transform: rotate(360deg); - } - } - @-webkit-keyframes spin { - 100% { - -webkit-transform: rotate(360deg); - } - } - @keyframes spin { - 100% { - -webkit-transform: rotate(360deg); - transform: rotate(360deg); - } - } - @keyframes spinback { - 100% { - -webkit-transform: rotate(-360deg); - transform: rotate(-360deg); - } - } - - ${loadingSpinner} -
v${ - browser.runtime.getManifest().version - }
`); - var html = document.getElementsByTagName("html")[0]; - html.append(loadinghtml.firstChild!); + const root = loadinghtml.firstChild as HTMLElement; + document.documentElement.append(root); + + applyVariantTheme(root, variant); + startCanvasForRoot(root, variant); } diff --git a/src/seqta/ui/loading-overlay.html b/src/seqta/ui/loading-overlay.html new file mode 100644 index 00000000..3ec381ce --- /dev/null +++ b/src/seqta/ui/loading-overlay.html @@ -0,0 +1,5 @@ + + +
+
+
diff --git a/src/seqta/ui/loading-spinner.html b/src/seqta/ui/loading-spinner.html index 59c70d22..1fa413cb 100644 --- a/src/seqta/ui/loading-spinner.html +++ b/src/seqta/ui/loading-spinner.html @@ -1,4 +1,4 @@ - + diff --git a/src/seqta/ui/loadingCanvas.ts b/src/seqta/ui/loadingCanvas.ts new file mode 100644 index 00000000..e7a02015 --- /dev/null +++ b/src/seqta/ui/loadingCanvas.ts @@ -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(); + }; +} diff --git a/src/seqta/ui/loadingVariants.ts b/src/seqta/ui/loadingVariants.ts new file mode 100644 index 00000000..49cc3ad8 --- /dev/null +++ b/src/seqta/ui/loadingVariants.ts @@ -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, + 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)); +}