import { needleLogoOnlySVG } from "../assets/index.js" import { showBalloonWarning } from "../debug/index.js"; import { _UMzFUJT } from "../engine_license.js"; import { Mathf } from "../engine_math.js"; import { LoadingProgressArgs } from "../engine_setup.js"; import { getParam } from "../engine_utils.js"; import { InternalAttributeUtils } from "../engine_utils_attributes.js"; const debug = getParam("debugloading"); const debugRendering = getParam("debugloadingrendering"); declare type LoadingStyleOption = "dark" | "light" | "auto"; /** Loading overlay layout. `centered` = bar centered, capped at 50% width, with an (optional) logo above it (default). `minimal` = thin bar pinned to the top. */ declare type LoadingLayoutOption = "minimal" | "centered"; /** @internal */ export class LoadingElementOptions { className?: string; additionalClasses?: string[]; } const LOADING_STYLE_ELEMENT_ID = "needle-engine-loading-style"; /** Tagged-template passthrough that just concatenates to a string. Its only purpose is to make * editors highlight/format the contents as CSS (the styled-components / lit VS Code extensions * recognise a `css` tag). No runtime dependency. */ const css = (strings: TemplateStringsArray, ...values: unknown[]): string => strings.reduce((out, str, i) => out + str + (i < values.length ? String(values[i]) : ""), ""); /** * Scoped styles for the default loading view. Every rule is scoped under `.needle-loading` * (or its `needle-loading__*` children) so nothing leaks to user content — in the shadow root * or the light DOM. To add/remove a look, edit this stylesheet and drive it from a data * attribute (`data-theme`, `data-layout`, `data-logo`) or a CSS custom property. */ const loadingStylesCss = css` .needle-loading { position: absolute; inset: 0; overflow: hidden; display: flex; align-items: center; justify-content: center; flex-direction: column; pointer-events: none; z-index: 0; background: var(--needle-loading-background, var(--needle-loading-background-default, transparent)); color: var(--needle-loading-color, var(--needle-loading-color-default, rgba(255,255,255,.3))); font-family: system-ui, Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; font-size: 1rem; /* Subtle scrim: a semi-transparent tint (--needle-loading-background) plus a backdrop blur (--needle-loading-blur) mute the scene behind the loader so the bar has reliable contrast and the load-in / pop-in is softened. Both are set per theme below and are fully overridable — a loading-background or poster attribute (or setting the vars to transparent / 0) opts out. */ backdrop-filter: blur(var(--needle-loading-blur, var(--needle-loading-blur-default, 0px))); -webkit-backdrop-filter: blur(var(--needle-loading-blur, var(--needle-loading-blur-default, 0px))); /* Appearance defaults. CONSUMERS override the UN-suffixed vars (e.g. needle-engine { --needle-loading-bar: red } or inline style) — the engine only sets the *-default vars, so a consumer value always wins. Values that VARY per theme are defined once, in the [data-theme] blocks below; theme-invariant defaults live here (defined once). */ --needle-loading-blur-default: 10px; /* scrim backdrop blur */ --needle-loading-bar-done-default: var(--needle-loading-bar, var(--needle-loading-bar-default)); /* fill color at 100% (defaults to the fill = no flip) */ --needle-loading-sheen-size-default: 200px; /* sheen tile width — smaller = more repeating bands */ --needle-loading-sheen-speed-default: .5s; /* time to scroll one sheen band */ } /* Light theme (light background): dark bar + light scrim */ .needle-loading[data-theme="light"] { --needle-loading-color-default: rgba(0,0,0,.6); --needle-loading-background-default: rgba(50, 50, 50, .3); --needle-loading-bar-track-default: rgba(126, 129, 121, 0.36); --needle-loading-bar-default: #c8f084; --needle-loading-bar-sheen-default: rgba(255, 255, 255, 0.5); } /* Dark theme (dark background): light bar + dark scrim */ .needle-loading[data-theme="dark"] { --needle-loading-color-default: rgba(255, 255, 255, 0.6); --needle-loading-background-default: rgba(29, 29, 29, 0.95); --needle-loading-bar-track-default: rgba(126, 146, 102, 0.2); --needle-loading-bar-default: #c8f084; --needle-loading-bar-sheen-default: rgba(255, 255, 255, 0.5); } /* A poster provides its own full-cover background, so skip the scrim + blur under it. */ .needle-loading[data-poster="true"] { --needle-loading-background-default: transparent; --needle-loading-blur-default: 0px; } .needle-loading__content { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; width: 100%; height: 100%; pointer-events: none; } /* Poster (optional background image). Consumer-stylable via the vars below, or fully via needle-engine::part(poster). Darken an image with --needle-loading-poster-overlay (a color painted on top of the image), e.g. rgba(0,0,0,.5) — it doesn't fight the blur filter. */ .needle-loading__poster { position: absolute; inset: 0; z-index: -1; overflow: hidden; margin: calc(-1 * var(--needle-loading-poster-blur, var(--needle-loading-poster-blur-default, 0px))); background: linear-gradient(var(--needle-loading-poster-overlay, transparent), var(--needle-loading-poster-overlay, transparent)), var(--needle-loading-poster) var(--needle-loading-poster-position, center) / var(--needle-loading-poster-size, cover) no-repeat; opacity: var(--needle-loading-poster-opacity, 1); filter: blur(var(--needle-loading-poster-blur, var(--needle-loading-poster-blur-default, 0px))); } /* Logo is opt-in (data-logo="true") and off by default; hidden entirely otherwise so it takes no layout space. Fades in when shown. */ .needle-loading__logo { display: none; user-select: none; object-fit: contain; width: 80%; height: min(1000px, max(15%, 50px)); transform: translateY(30px); opacity: 0.0000001; transition: transform 1s ease-out .2s, opacity .3s ease-in-out .2s; } .needle-loading[data-logo="true"] .needle-loading__logo { display: block; } .needle-loading__logo[data-visible="true"] { opacity: 1; transform: translateY(0px); } .needle-loading__bar-container { display: flex; background-color: var(--needle-loading-bar-track, var(--needle-loading-bar-track-default)); opacity: 0; transition: opacity 1s ease-in-out; /* MINIMAL (default): thin bar pinned to the top edge, full width */ position: absolute; left: 0; top: 0; width: 100%; height: 5px; overflow: hidden; } .needle-loading__bar-container[data-visible="true"] { opacity: 1; } .needle-loading__bar { position: relative; width: 0%; height: 100%; background-color: var(--needle-loading-bar, var(--needle-loading-bar-default)); /* smooth every progress change (incl. jumps between load events) so the fill is always fluid */ transition: width .3s ease-out, background-color .3s ease-out; } /* once complete the fill switches to the "done" color (same as the fill by default) */ .needle-loading__bar[data-complete="true"] { background-color: var(--needle-loading-bar-done, var(--needle-loading-bar-done-default)); } /* animated sheen: a soft highlight tiled across the fill so the gradient REPEATS multiple times, then scrolled by exactly one tile for a seamless loop — like the indeterminate compression/job progress bars. Density via --needle-loading-sheen-size, speed via --needle-loading-sheen-speed. */ .needle-loading__bar::after { content: ""; position: absolute; inset: 0; background-image: linear-gradient(90deg, transparent 0%, var(--needle-loading-bar-sheen, var(--needle-loading-bar-sheen-default)) 50%, transparent 100%); background-size: var(--needle-loading-sheen-size, var(--needle-loading-sheen-size-default, 60px)) 100%; background-repeat: repeat; animation: needle-loading-sheen var(--needle-loading-sheen-speed, var(--needle-loading-sheen-speed-default, 1s)) linear infinite; } /* stop the sheen once complete so the finished bar reads as settled */ .needle-loading__bar[data-complete="true"]::after { animation: none; opacity: 0; transition: opacity .2s ease-out; } @keyframes needle-loading-sheen { from { background-position: 0 0; } to { background-position: var(--needle-loading-sheen-size, var(--needle-loading-sheen-size-default, 60px)) 0; } } @media (prefers-reduced-motion: reduce) { .needle-loading__bar::after { animation: none; } } /* CENTERED: the bar is centered and capped at 50% width. Without a logo it sits at true center; with a logo the logo+bar group is lifted to the golden-ratio line (~12% above center) to balance it. */ .needle-loading[data-layout="centered"] .needle-loading__bar-container { position: relative; left: auto; top: auto; width: min(50%, 400px); height: 5px; border-radius: 3px; } .needle-loading[data-layout="centered"][data-logo="true"] .needle-loading__content { transform: translateY(-11.8%); } /* Sheen density/speed per layout (engine defaults — consumers still override the un-suffixed vars) */ .needle-loading[data-layout="minimal"] { --needle-loading-sheen-size-default: 30vw; --needle-loading-sheen-speed-default: .8s; } `; /** Injects the loading stylesheet into the given root (shadow root or light-DOM element) exactly once. */ function ensureLoadingStyles(root: HTMLElement | ShadowRoot) { if (root.querySelector(`#${LOADING_STYLE_ELEMENT_ID}`)) return; const style = document.createElement("style"); style.id = LOADING_STYLE_ELEMENT_ID; style.textContent = loadingStylesCss; root.appendChild(style); } /** @internal */ export interface ILoadingViewHandler { onLoadingBegin(message?: string) onLoadingUpdate(progress: LoadingProgressArgs | number, message?: string); onLoadingFinished(message?: string); setMessage(string: string); } let currentFileProgress = 0; let currentFileName: string; /** @internal */ export function calculateProgress01(progress: LoadingProgressArgs) { if (debug) console.log(progress.progress.loaded.toFixed(0) + "/" + progress.progress.total.toFixed(0), progress); const count = progress.count; const total: number | undefined = progress.progress.total; // if the progress event total amount is unknown / not reported // we slowly move the progress bar forward if (total === 0 || total === undefined) { // reset the temp progress when the file has changed if (currentFileName !== progress.name) currentFileProgress = 0; currentFileName = progress.name; // slowly move the progress bar forward currentFileProgress += (1 - currentFileProgress) * .001; if (debug) showBalloonWarning("Loading " + progress.name + " did not report total size"); } else { currentFileProgress = progress.progress.loaded / total; } const prog = progress.index / count + currentFileProgress / count; return Mathf.clamp01(prog); } /** @internal */ export class EngineLoadingView implements ILoadingViewHandler { static LoadingContainerClassName = "loading"; // the raw progress loadingProgress: number = 0; /** Usually the NeedleEngineHTMLElement */ private _element: HTMLElement; private _progress: number = 0; private _allowCustomLoadingElement: boolean = true; private _loadingElement?: HTMLElement; private _loadingTextContainer: HTMLElement | null = null; private _loadingBar: HTMLElement | null = null; private _messageContainer: HTMLElement | null = null; private _loadingElementOptions?: LoadingElementOptions; /** * Creates a new loading view * @param owner the element that will contain the loading view (should be the NeedleEngineHTMLElement) */ constructor(owner: HTMLElement, opts?: LoadingElementOptions) { this._element = owner; this._loadingElementOptions = opts; } async onLoadingBegin(message?: string) { const _element = this._element.shadowRoot || this._element; if (debug) console.warn("Begin Loading") if (!this._loadingElement) { for (let i = 0; i < _element.children.length; i++) { const el = _element.children[i] as HTMLElement; if (el.classList.contains(EngineLoadingView.LoadingContainerClassName)) { if (!this._allowCustomLoadingElement) { if (debug) console.warn("Remove custom loading container") _element.removeChild(el); continue; } this._loadingElement = this.createLoadingElement(el); } } if (!this._loadingElement) this._loadingElement = this.createLoadingElement(); } this._progress = 0; this.loadingProgress = 0; this._loadingElement.style.display = "flex"; _element.appendChild(this._loadingElement); this.smoothProgressLoop(); this.setMessage(message ?? ""); } onLoadingUpdate(progress: LoadingProgressArgs | ProgressEvent | number, message?: string) { if (!this._loadingElement?.parentNode) { return; } // console.log(callback.name, callback.progress.loaded / callback.progress.total, callback.index + "/" + callback.count); let total01 = 0; if (typeof progress === "number") { total01 = progress; } else { if ("index" in progress) total01 = calculateProgress01(progress); if (!message && "name" in progress) this.setMessage("loading " + progress.name); } this.loadingProgress = total01; if (message) this.setMessage(message); this.updateDisplay(); } onLoadingFinished() { if (debug) console.warn("Finished Loading"); if (!debugRendering) { this.loadingProgress = 1; this.onDoneLoading(); } } setMessage(message: string) { if (this._messageContainer) { this._messageContainer.innerText = message; } } private _progressLoop: any; private smoothProgressLoop() { if (this._progressLoop) return; let dt = 1 / 12; if (debugRendering) { dt = 1 / 500; if (typeof debugRendering === "number") dt *= debugRendering; } this._progressLoop = setInterval(() => { // The bar width is smoothed by its CSS `width` transition — we only push the latest // target progress each tick. (A JS lerp here would double-smooth and visibly lag.) this._progress = this.loadingProgress; this.updateDisplay(); }, dt); } private onDoneLoading() { // Drive the bar to a full 100% first (the CSS width transition animates the last stretch), // then stop the loop so it can't leave the bar sitting below 100%. this._progress = 1; this.updateDisplay(); if (this._progressLoop) clearInterval(this._progressLoop); this._progressLoop = null; const element = this._loadingElement; if (!element) return; if (debug) console.log("Hiding loading element"); const fillMs = 10; // let the width transition reach 100% const holdMs = 0; // briefly show the completed bar at 100% const fgFadeMs = 0; // fade the bar + logo before the background reveals the 3D content // After the bar has filled and held at 100%, fade the foreground out, then the overlay. setTimeout(() => { const foreground = element.querySelectorAll(".needle-loading__bar-container, .needle-loading__logo"); foreground.forEach(e => e.animate([{ opacity: 1 }, { opacity: 0 }], { duration: fgFadeMs, easing: 'ease-out', fill: 'forwards' })); setTimeout(() => { element.animate([ { opacity: 1 }, { opacity: 0 } ], { duration: 0, easing: 'ease-in-out', }).addEventListener('finish', () => { element.style.display = "none"; element.remove(); }); }, fgFadeMs); }, fillMs + holdMs); } private updateDisplay() { const t = this._progress; const percent = (t * 100).toFixed(0) + "%"; if (this._loadingBar) { this._loadingBar.style.width = t * 100 + "%"; // flag completion so the CSS can switch to the "done" color and settle the sheen if (t >= 1) this._loadingBar.dataset["complete"] = "true"; } if (this._loadingTextContainer) this._loadingTextContainer.textContent = percent; } private createLoadingElement(existing?: HTMLElement) { if (debug && !existing) console.log("Creating loading element"); const el = this._loadingElement = existing || document.createElement("div"); // Resolve the theme to exactly "dark" or "light" (auto / unset / invalid -> OS preference). // Guaranteeing one of the two means the [data-theme] blocks always provide the defaults, // so the base block doesn't need to duplicate them. let theme: string | null = this._element.getAttribute("loading-style"); if (theme !== "dark" && theme !== "light") theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? "dark" : "light"; // Resolve the layout (centered is the default; minimal = thin bar pinned to the top) let layout = (this._element.getAttribute("loading-layout") || "centered").toLowerCase() as LoadingLayoutOption; if (layout !== "minimal") layout = "centered"; // Logo is optional and off by default. `loading-logo` is a pure on/off toggle // (falsey "0"/"false" or absent = off). The image comes from the `-logo-src` attributes // below, mirroring the QR code's `qrcode-logo-src` / `logo-src` convention. const logoToggle = InternalAttributeUtils.getAttributeAndCheckFalsey(this._element, "loading-logo"); const showLogo = logoToggle !== null && logoToggle !== false; const hasLicense = _UMzFUJT(); const className = this._loadingElementOptions?.className ?? EngineLoadingView.LoadingContainerClassName; el.classList.add(className); if (this._loadingElementOptions?.additionalClasses) for (const c of this._loadingElementOptions.additionalClasses) el.classList.add(c); // A user-provided (`existing`) `.loading` element is their own loading screen — we don't // impose the engine's container styling on it. Only the engine-created default view gets it. if (!existing) { el.classList.add("needle-loading"); el.dataset["theme"] = theme; el.dataset["layout"] = layout; el.dataset["logo"] = String(showLogo); const background = this._element.getAttribute("loading-background"); if (background) { // A custom background replaces the default scrim (and its blur) el.style.setProperty("--needle-loading-background", background); el.style.setProperty("--needle-loading-blur", "0px"); } } // Inject the (once) scoped stylesheet next to the loading element ensureLoadingStyles(el); const content = document.createElement("div"); content.classList.add("needle-loading__content"); el.appendChild(content); const poster = this._element.getAttribute("poster"); if (poster !== null && poster !== "0") { el.dataset["poster"] = "true"; // disables the scrim + blur (the poster is the background) const posterEl = document.createElement("div"); posterEl.classList.add("needle-loading__poster"); posterEl.setAttribute("part", "poster"); // allow `needle-engine::part(poster)` styling from outside the shadow root const blur = poster?.length ? "0px" : "50px"; posterEl.style.setProperty("--needle-loading-poster", `url('${poster?.length ? poster : "/include/poster.webp"}')`); // the *-default var so a consumer's --needle-loading-poster-blur still overrides it posterEl.style.setProperty("--needle-loading-poster-blur-default", blur); el.appendChild(posterEl); } const logo = document.createElement("img"); logo.classList.add("needle-loading__logo"); logo.src = needleLogoOnlySVG; if (hasLicense) { // Custom logo image: loading-specific override first, then the shared `logo-src` // (mirrors QR's `qrcode-logo-src` -> `logo-src`). Falsey values skip that source. let src: false | string | null = InternalAttributeUtils.getAttributeAndCheckFalsey(this._element, "loading-logo-src"); src ||= InternalAttributeUtils.getAttributeAndCheckFalsey(this._element, "logo-src"); if (typeof src === "string") logo.src = src; } content.appendChild(logo); // Fade the logo in on the next tick (lets the CSS transition run) when it should be visible if (showLogo) setTimeout(() => { logo.dataset["visible"] = "true"; }, 1); const loadingBarContainer = document.createElement("div"); loadingBarContainer.classList.add("needle-loading__bar-container"); content.appendChild(loadingBarContainer); setTimeout(() => { loadingBarContainer.dataset["visible"] = "true"; }, 1); this._loadingBar = document.createElement("div"); this._loadingBar.classList.add("needle-loading__bar"); loadingBarContainer.appendChild(this._loadingBar); return el; } /** * Dev-only: show the loading overlay with a looping fake progress so the loading UI can be * previewed and iterated on without loading a scene. Attributes (`loading-style`, * `loading-layout`, `loading-logo`, `poster`, ...) are read from `options` or from `target`. * * Auto-runs when the URL contains `?loadingpreview` (optionally `?loadingpreview=centered`). * Can also be called manually, e.g. `document.querySelector("needle-engine")` as target. * * @returns a function that stops the preview and hides the overlay. */ static preview(target?: HTMLElement, options?: { layout?: LoadingLayoutOption, theme?: LoadingStyleOption, logo?: boolean, logoSrc?: string, poster?: string }): () => void { // A detached element carries the attributes the loading view reads const host = target ?? document.createElement("div"); if (options?.layout) host.setAttribute("loading-layout", options.layout); if (options?.theme) host.setAttribute("loading-style", options.theme); if (options?.logo !== undefined) host.setAttribute("loading-logo", String(options.logo)); if (options?.logoSrc !== undefined) host.setAttribute("loading-logo-src", options.logoSrc); if (options?.poster !== undefined) host.setAttribute("poster", options.poster); // Give the preview a backdrop that matches the resolved theme — otherwise a light-theme // loader would sit on a dark box (or vice versa) and misrepresent the contrast. const styleAttr = host.getAttribute("loading-style"); const theme = styleAttr === "dark" || styleAttr === "light" ? styleAttr : (window.matchMedia('(prefers-color-scheme: dark)').matches ? "dark" : "light"); const backdrop = theme === "dark" ? "#1a1a1a" : "#e8e8e8"; const previousBackground = host.style.background; if (!target) { host.style.cssText = `position:fixed; inset:0; z-index:99999; background:${backdrop};`; document.body.appendChild(host); } else { // dev-only representative backdrop on the target element (restored when the preview stops) host.style.background = backdrop; } const view = new EngineLoadingView(host); view.onLoadingBegin("preview"); console.log("[Needle] Loading preview started. Edit the loading styles and reload to iterate. Call the returned function to stop."); let p = 0; const interval = setInterval(() => { p += 0.02; if (p >= 1) p = 0; // loop so it never disappears view.onLoadingUpdate(Math.min(p, 0.98)); }, 60); return () => { clearInterval(interval); view.onLoadingFinished(); if (!target) host.remove(); else host.style.background = previousBackground; }; } }