From 3ef50c4448095c93717aad4a28a70142ea31a028 Mon Sep 17 00:00:00 2001 From: Artur Mukhamadiev Date: Tue, 15 Sep 2026 12:00:26 +0300 Subject: [PATCH] frontend: thinking-orbs loading indicator and CSS book logo Replace the gear spinner with the thinking-orbs canvas engine (vendored framework-free build, driven by js/orb.js in the "solving" state) and the inline SVG brand icon with a pure CSS book whose cover flips open on page load and while the brand is hovered (js/logo.js). --- README.md | 2 +- frontend/README.md | 10 +- frontend/css/style.css | 127 +++++- frontend/index.html | 16 +- frontend/js/app.js | 6 + frontend/js/logo.js | 72 +++ frontend/js/orb.js | 97 +++++ frontend/package-lock.json | 15 +- frontend/package.json | 3 +- frontend/tests/e2e_runner.js | 6 +- frontend/vendor/thinking-orbs.LICENSE | 21 + frontend/vendor/thinking-orbs.engine.js | 557 ++++++++++++++++++++++++ 12 files changed, 891 insertions(+), 41 deletions(-) create mode 100644 frontend/js/logo.js create mode 100644 frontend/js/orb.js create mode 100644 frontend/vendor/thinking-orbs.LICENSE create mode 100644 frontend/vendor/thinking-orbs.engine.js diff --git a/README.md b/README.md index defbf08..d8c44bc 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ The repository also contains the original standalone | --- | --- | --- | | `backend/` | FastAPI service: credentials in memory, Confluence/model upstreams, authoritative tool history, container lifecycle, artifact downloads, serves the frontend | [backend/README.md](backend/README.md) | | `agent/` | Container image: pinned pi SDK bridge, Python supervisor, local tools, artifact exporter | [agent/README.md](agent/README.md) | -| `frontend/` | Static UI (vendored marked + DOMPurify), same-origin mock server for UI development | [frontend/README.md](frontend/README.md) | +| `frontend/` | Static UI (vendored marked, DOMPurify and thinking-orbs engine), same-origin mock server for UI development | [frontend/README.md](frontend/README.md) | | `tests/integration/` | Integration-stage tests connecting the real components | this file | | `deploy/`, `scripts/`, `Makefile` | Deployment configuration and entry points | this file | diff --git a/frontend/README.md b/frontend/README.md index b2bd569..a684c5d 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -8,17 +8,21 @@ Minimalist, secure Web UI for Confluence Research, designed to operate against t frontend/ ├── index.html # Main HTML entrypoint (clean white minimalist theme) ├── css/ -│ └── style.css # Responsive styling, accessible components, gear animation +│ └── style.css # Responsive styling, accessible components, thinking-orb and logo styles ├── js/ │ ├── app.js # State transitions, keyboard handling, memory credentials, staleness guards │ ├── api.js # Relative /api/v1/... fetch boundary with UTF-8 byte validation │ ├── render.js # marked.js + DOMPurify, fail-safe render, bounded sectioning -│ └── history.js # Sources, lazy bounded history serialization, artifacts listing +│ ├── history.js # Sources, lazy bounded history serialization, artifacts listing +│ ├── orb.js # Vanilla canvas driver for the vendored thinking-orbs engine (loading view) +│ └── logo.js # Book logo playback: CSS cover flip on page open and brand hover ├── vendor/ # Pinned vendor libraries & licenses (locally served) │ ├── marked.min.js │ ├── marked.LICENSE │ ├── purify.min.js -│ └── dompurify.LICENSE +│ ├── dompurify.LICENSE +│ ├── thinking-orbs.engine.js # Framework-free engine build of thinking-orbs 0.3.1 +│ └── thinking-orbs.LICENSE ├── dev/ │ ├── mock-server.js # Zero-dependency same-origin mock server & scenario runner │ ├── scenario-toolbar.js # External dev toolbar script (CSP compliant, no inline scripts) diff --git a/frontend/css/style.css b/frontend/css/style.css index 1445e67..5c3c222 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -89,6 +89,106 @@ body { letter-spacing: -0.01em; } +/* Book logo: pure CSS line art on a 24-unit grid (1em = 24 units). + The front cover swings open around its left edge (js/logo.js toggles + .book-open on page load and while the brand is hovered). */ +.book { + --book-line: calc(1.6em / 24); + position: relative; + display: inline-block; + width: 1em; + height: 1em; + font-size: 24px; + flex-shrink: 0; + color: var(--color-text-primary); + perspective: 4em; + perspective-origin: 50% 50%; +} + +.book-page, +.book-cover, +.book-edge, +.book-label { + position: absolute; + box-sizing: border-box; + border: var(--book-line) solid currentColor; +} + +/* Inner page revealed while the cover is open */ +.book-page { + left: calc(4em / 24); + top: calc(2em / 24); + width: calc(16em / 24); + height: calc(17em / 24); + border-radius: calc(3em / 24); + background: var(--color-bg-primary); +} + +.book-page::after { + content: ""; + position: absolute; + left: calc(3em / 24); + right: calc(3em / 24); + top: calc(3em / 24); + bottom: calc(2em / 24); + background: repeating-linear-gradient( + to bottom, + currentColor 0, + currentColor calc(1.2em / 24), + transparent calc(1.2em / 24), + transparent calc(3.2em / 24) + ); +} + +/* Page block seen along the bottom */ +.book-edge { + left: calc(4em / 24); + top: calc(18em / 24); + width: calc(16em / 24); + height: calc(4em / 24); + border-top: 0; + border-radius: 0 0 calc(3em / 24) calc(3em / 24); +} + +/* Front cover, hinged on its left edge */ +.book-cover { + left: calc(4em / 24); + top: calc(2em / 24); + width: calc(16em / 24); + height: calc(17em / 24); + border-radius: calc(3em / 24); + background: var(--color-bg-primary); + transform-origin: left center; + transform: rotateY(0deg); + will-change: transform; +} + +.book-label { + left: calc(3em / 24); + top: calc(3em / 24); + width: calc(10em / 24); + height: calc(3.5em / 24); + border-width: calc(1.2em / 24); + border-radius: calc(1.2em / 24); +} + +.book-open .book-cover { + animation: book-flip 1.2s ease-in-out infinite; +} + +@keyframes book-flip { + 0% { transform: rotateY(0deg); } + 35% { transform: rotateY(-62deg); } + 60% { transform: rotateY(-62deg); } + 100% { transform: rotateY(0deg); } +} + +@media (prefers-reduced-motion: reduce) { + .book-open .book-cover { + animation: none; + } +} + .key-btn { position: relative; background: transparent; @@ -282,27 +382,12 @@ body { margin-bottom: 20px; } -.gear-spinner { - width: 48px; - height: 48px; - color: var(--color-accent); - stroke-width: 2px; - animation: spin 1.8s cubic-bezier(0.4, 0, 0.2, 1) infinite; -} - -@keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: reduce) { - .gear-spinner { - animation: none; - } +/* thinking-orbs canvas (js/orb.js); reduced motion renders a static frame */ +.thinking-orb { + display: block; + width: 64px; + height: 64px; + margin: 0 auto; } .loading-status { diff --git a/frontend/index.html b/frontend/index.html index 0d0e1b0..882d301 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -12,11 +12,12 @@
-
- +
+ Confluence Research
diff --git a/frontend/js/app.js b/frontend/js/app.js index 481e149..fa1badc 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -6,6 +6,8 @@ import { verifyCredentials, submitQuery, downloadArtifact, validateCredentials } from './api.js'; import { MarkdownRenderer, exportToMarkdown, renderWarnings } from './render.js'; import { renderPagesAccessed, renderToolHistory, renderArtifacts } from './history.js'; +import { mountThinkingOrb } from './orb.js'; +import { initBookLogo } from './logo.js'; // Application memory state let committedCredentials = null; // { url: string, pat: string } | null @@ -41,6 +43,10 @@ document.addEventListener('DOMContentLoaded', () => { viewLoading = document.getElementById('view-loading'); viewResult = document.getElementById('view-result'); + // Decorative animations (page is a fixed light theme, so the orb ink is pinned to light) + mountThinkingOrb(document.getElementById('thinking-orb'), { state: 'solving', size: 64, theme: 'light' }); + initBookLogo(document.getElementById('app-brand'), document.getElementById('brand-logo')); + // Prompt View promptInput = document.getElementById('prompt-input'); submitBtn = document.getElementById('submit-btn'); diff --git a/frontend/js/logo.js b/frontend/js/logo.js new file mode 100644 index 0000000..35a6166 --- /dev/null +++ b/frontend/js/logo.js @@ -0,0 +1,72 @@ +/** + * Book logo animation control. + * + * The logo is pure CSS (see `.book` in css/style.css); the cover flips while + * the `book-open` class is present. Playback runs briefly when the page opens + * and while the brand is hovered. Stops are deferred to the end of the + * current loop (the `animationiteration` event) so the cover never snaps shut + * mid-flip. + */ + +const OPEN_PLAY_MS = 2000; +const LOOP_MS = 1300; // one `book-flip` cycle (1.2s) plus slack, used if the animation clock is unavailable +const OPEN_CLASS = 'book-open'; + +/** + * @param {HTMLElement} brand Hover target wrapping the logo. + * @param {HTMLElement} book The `.book` element. + */ +export function initBookLogo(brand, book) { + if (!brand || !book) return; + if (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches) return; + + let stopRequested = false; + let hovering = false; + let openTimer = 0; + let fallbackTimer = 0; + + const stopNow = () => { + window.clearTimeout(fallbackTimer); + fallbackTimer = 0; + stopRequested = false; + book.classList.remove(OPEN_CLASS); + }; + const play = () => { + window.clearTimeout(fallbackTimer); + fallbackTimer = 0; + stopRequested = false; + book.classList.add(OPEN_CLASS); + }; + const msUntilLoopEnd = () => { + const cover = book.querySelector('.book-cover'); + const animation = cover && typeof cover.getAnimations === 'function' ? cover.getAnimations()[0] : null; + const timing = animation && animation.effect && typeof animation.effect.getTiming === 'function' ? animation.effect.getTiming() : null; + const duration = timing && typeof timing.duration === 'number' ? timing.duration : 0; + if (!animation || !duration || typeof animation.currentTime !== 'number') return LOOP_MS; + return duration - (animation.currentTime % duration) + 50; + }; + const stopAtLoopEnd = () => { + stopRequested = true; + // Iteration events only arrive while the tab is rendering frames; in a + // hidden or throttled tab, fall back to a timer aligned to the loop end. + window.clearTimeout(fallbackTimer); + fallbackTimer = window.setTimeout(stopNow, msUntilLoopEnd()); + }; + + book.addEventListener('animationiteration', () => { + if (stopRequested && !hovering) stopNow(); + }); + + brand.addEventListener('mouseenter', () => { + hovering = true; + window.clearTimeout(openTimer); + play(); + }); + brand.addEventListener('mouseleave', () => { + hovering = false; + stopAtLoopEnd(); + }); + + play(); + openTimer = window.setTimeout(stopAtLoopEnd, OPEN_PLAY_MS); +} diff --git a/frontend/js/orb.js b/frontend/js/orb.js new file mode 100644 index 0000000..ef0a76a --- /dev/null +++ b/frontend/js/orb.js @@ -0,0 +1,97 @@ +/** + * Vanilla driver for the thinking-orbs canvas engine. + * + * The upstream package ships a React component; this module reproduces its + * behaviour on top of the framework-free engine build vendored under + * `vendor/thinking-orbs.engine.js` (same-origin, CSP compliant): + * device-pixel-ratio capped at 2, static frame under reduced motion, and + * automatic pausing while off-screen or in a hidden tab. + */ + +import { resolvePreset, MODE_DRAWS } from '../vendor/thinking-orbs.engine.js'; + +const REDUCED_MOTION_FRAME = 0.6; + +/** + * Mounts an animated orb on an existing . + * @param {HTMLCanvasElement} canvas + * @param {{ state?: string, size?: 64|20, theme?: 'light'|'dark', speed?: number }} [options] + * @returns {{ destroy: () => void } | null} + */ +export function mountThinkingOrb(canvas, options = {}) { + if (!canvas || typeof canvas.getContext !== 'function') return null; + + const state = options.state || 'working'; + const size = options.size === 20 ? 20 : 64; + const dark = options.theme === 'dark'; + const speedMultiplier = typeof options.speed === 'number' && options.speed > 0 ? options.speed : 1; + + const dpr = Math.min(2, (typeof devicePixelRatio === 'number' && devicePixelRatio) || 1); + canvas.width = Math.round(size * dpr); + canvas.height = Math.round(size * dpr); + canvas.style.width = `${size}px`; + canvas.style.height = `${size}px`; + + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + + const { mode, speed, opts } = resolvePreset(state, size); + const draw = MODE_DRAWS[mode]; + const clock = speed * speedMultiplier; + + const paintAt = (t) => { + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, size, size); + draw(ctx, size, t, dark, opts); + }; + + const reducedMotion = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches; + if (reducedMotion) { + paintAt(REDUCED_MOTION_FRAME); + return { destroy() {} }; + } + + let frameId = 0; + let running = false; + let visible = true; + + const tick = () => { + paintAt((performance.now() / 1000) * clock); + if (running) frameId = requestAnimationFrame(tick); + }; + const start = () => { + if (running) return; + running = true; + frameId = requestAnimationFrame(tick); + }; + const stop = () => { + running = false; + cancelAnimationFrame(frameId); + }; + + paintAt((performance.now() / 1000) * clock); + + const observer = typeof IntersectionObserver === 'function' + ? new IntersectionObserver(([entry]) => { + visible = entry.isIntersecting; + if (visible && document.visibilityState !== 'hidden') start(); + else stop(); + }) + : null; + if (observer) observer.observe(canvas); + + const onVisibilityChange = () => { + if (document.visibilityState === 'hidden') stop(); + else if (visible) start(); + }; + document.addEventListener('visibilitychange', onVisibilityChange); + if (!observer) start(); + + return { + destroy() { + stop(); + if (observer) observer.disconnect(); + document.removeEventListener('visibilitychange', onVisibilityChange); + } + }; +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3bda0a0..35cb589 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,10 +7,10 @@ "": { "name": "frontend", "version": "1.0.0", - "license": "ISC", "dependencies": { - "dompurify": "^3.4.15", - "marked": "^18.0.13" + "dompurify": "3.4.15", + "marked": "18.0.13", + "thinking-orbs": "0.3.1" } }, "node_modules/@types/trusted-types": { @@ -40,6 +40,15 @@ "engines": { "node": ">= 20" } + }, + "node_modules/thinking-orbs": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/thinking-orbs/-/thinking-orbs-0.3.1.tgz", + "integrity": "sha512-3BG1aeB1RUTxItCml/BBuIz5JRM4kZqGuyx+vouv0fXTtcR9ZNoKjWGneHPx94y74GxgArwJZ1qbJR5dt54kSw==", + "license": "MIT", + "peerDependencies": { + "react": ">=18.0.0" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 547fed1..54936d5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "dompurify": "3.4.15", - "marked": "18.0.13" + "marked": "18.0.13", + "thinking-orbs": "0.3.1" } } diff --git a/frontend/tests/e2e_runner.js b/frontend/tests/e2e_runner.js index 200d5ff..4c37a0e 100644 --- a/frontend/tests/e2e_runner.js +++ b/frontend/tests/e2e_runner.js @@ -216,7 +216,7 @@ async function runTests() { }); // Test 5: Research Query Submission & Loading View - await step('Query Submission & Loading Gear Spinner', async () => { + await step('Query Submission & Loading Thinking Orb', async () => { // Set prompt await cdp.eval('document.getElementById("prompt-input").value = "How do I deploy service X?"'); await cdp.eval('document.getElementById("submit-btn").click()'); @@ -228,8 +228,8 @@ async function runTests() { const statusText = await cdp.eval('document.querySelector(".loading-status").textContent'); assert.equal(statusText, 'Agent researching Confluence...'); - const gearPresent = await cdp.eval('!!document.querySelector(".gear-spinner")'); - assert.equal(gearPresent, true, 'Gear spinner glyph must be present'); + const orbPresent = await cdp.eval('!!document.querySelector("canvas.thinking-orb")'); + assert.equal(orbPresent, true, 'Thinking orb canvas must be present'); // Wait for query completion and result view await new Promise((r) => setTimeout(r, 600)); diff --git a/frontend/vendor/thinking-orbs.LICENSE b/frontend/vendor/thinking-orbs.LICENSE new file mode 100644 index 0000000..0e9405a --- /dev/null +++ b/frontend/vendor/thinking-orbs.LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jakub Antalik + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/frontend/vendor/thinking-orbs.engine.js b/frontend/vendor/thinking-orbs.engine.js new file mode 100644 index 0000000..1a5c1f0 --- /dev/null +++ b/frontend/vendor/thinking-orbs.engine.js @@ -0,0 +1,557 @@ +function U(n, s, t) { + return n + (s - n) * t; +} +function nt(n) { + return n - Math.floor(n); +} +function G(n, s) { + const t = Math.floor(n), r = Math.floor(s); + let a = n - t, o = s - r; + a = a * a * (3 - 2 * a), o = o * o * (3 - 2 * o); + const c = E(t, r), M = E(t + 1, r), h = E(t, r + 1), m = E(t + 1, r + 1); + return c + (M - c) * a + (h - c) * o + (c - M - h + m) * a * o; +} +function E(n, s) { + const t = Math.sin(n * 12.9898 + s * 78.233) * 43758.5453; + return t - Math.floor(t); +} +function J(n, s) { + const t = Math.PI * (3 - Math.sqrt(5)), r = 1 - 2 * (n + 0.5) / s, a = Math.sqrt(1 - r * r), o = n * t; + return [a * Math.cos(o), r, a * Math.sin(o)]; +} +function et(n, s) { + return Math.atan2(Math.sin(n - s), Math.cos(n - s)); +} +function _(n, s, t, r, a) { + const o = Math.sin(s), c = Math.cos(s), M = Math.sin(n), h = Math.cos(n); + return (m, D, p) => { + const e = m * h + p * M, l = -m * M + p * h, R = D * c - l * o, w = D * o + l * c; + return [t + e * a, r - R * a, w]; + }; +} +function rt(n, s, t, r = 0.3) { + for (const a of s) { + const o = a.a ?? 1, c = Math.min(1, Math.max(0, a.white)), M = Math.round((t ? 1 - c : c) * 255); + n.fillStyle = `rgba(${M},${M},${M},${o})`, n.beginPath(), n.arc(a.x, a.y, a.r, 0, Math.PI * 2), n.fill(); + } +} +function it(n, s, t) { + for (const r of s) { + const a = r.a ?? 1, o = Math.min(1, Math.max(0, r.white)), c = Math.round((t ? 1 - o : o) * 255); + n.strokeStyle = `rgba(${c},${c},${c},${a})`, n.lineWidth = r.w, n.beginPath(), n.moveTo(r.x1, r.y1), n.lineTo(r.x2, r.y2), n.stroke(); + } +} +function L(n, s, t = 0.3) { + const r = []; + for (const a of n) + (a.a ?? 1) < 0.02 || (a.r = Math.max(t, a.r), r.push(a)); + return r.sort((a, o) => a.z - o.z), { dots: r, lines: s.filter((a) => (a.a ?? 1) >= 0.02) }; +} +function ht(n, s, t) { + s.lines.length && it(n, s.lines, t), rt(n, s.dots, t); +} +function $(n, s) { + return (n / 300) ** s; +} +const Mt = (n, s, t) => { + const r = n / 2, a = n / 2, o = n / 2 * 0.76, c = _(s * 0.4, 0.3, r, a, 1), M = $(n, t.rsPow ?? 0.6), h = [], m = t.ghostN ?? 150; + for (let e = 0; e < m; e++) { + const l = J(e, m), [R, w, i] = c(l[0] * o, l[1] * o, l[2] * o), u = (i / o + 1) / 2; + h.push({ x: R, y: w, z: i, r: 0.8 * M, white: 0.78, a: 0.1 + 0.22 * u }); + } + const D = t.strandN ?? 52, p = t.turns ?? 3; + for (let e = 0; e < 3; e++) { + const l = e / 3 * 2 * Math.PI; + for (let R = 0; R < D; R++) { + const w = (nt(R / D + s * 0.045) * 2 - 1) * 0.96, i = Math.sqrt(Math.max(0, 1 - w * w)), u = Math.min(1, (1 - Math.abs(w)) / 0.1), y = w * Math.PI * p + l, b = 1 + 0.075 * Math.sin(w * Math.PI * p * 2 + l * 2 + s * 0.8), f = i * o * b, [P, x, g] = c(Math.cos(y) * f, w * o * b, Math.sin(y) * f), d = (g / o + 1) / 2; + h.push({ + x: P, + y: x, + z: g, + r: ((t.rBase ?? 1.2) + (t.rDepth ?? 1.8) * d) * M, + white: 0.55 - 0.45 * d, + a: u * (0.45 + 0.55 * d) + }); + } + } + return L(h, [], t.rMin); +}; +function lt(n, s, t, r) { + const a = 2 * s * t + r, o = n % a, c = new Array(s).fill(0); + let M = -1; + if (o < 2 * s * t) { + const h = Math.floor(o / t), m = (o - h * t) / t, p = 1 - (1 - Math.min(1, m / 0.7)) ** 3; + if (h < s) { + for (let e = 0; e < h; e++) c[e] = 1; + c[h] = p, M = h; + } else { + const e = 2 * s - 1 - h; + for (let l = 0; l < e; l++) c[l] = 1; + c[e] = 1 - p, M = e; + } + } + return { amount: c, active: M }; +} +function pt(n, s, t) { + let [r, a, o] = n, c = !1; + for (let M = 0; M < s.length; M++) { + if (t.amount[M] <= 0) continue; + const h = s[M], m = h.axis === 0 ? r : h.axis === 1 ? a : o; + if (m < h.lo || m >= h.hi) continue; + M === t.active && (c = !0); + const D = h.ang * t.amount[M], p = Math.cos(D), e = Math.sin(D); + if (h.axis === 0) { + const l = a * p - o * e; + o = a * e + o * p, a = l; + } else if (h.axis === 1) { + const l = r * p + o * e; + o = -r * e + o * p, r = l; + } else { + const l = r * p - a * e; + a = r * e + a * p, r = l; + } + } + return [r, a, o, c]; +} +function ut(n) { + const s = []; + for (let t = 0; t < n; t++) { + const r = Math.min(2, Math.floor(E(t, 2.3) * 3)), a = -1 + 0.5 * Math.min(3, Math.floor(E(t, 5.9) * 4)), o = E(t, 7.7) < 0.5 ? 1 : -1; + s.push({ axis: r, lo: a, hi: a + 0.5, ang: o * Math.PI / 2 }); + } + return s; +} +const ft = (n, s, t) => { + const a = n / 2, o = n / 2, c = n / 2 * 0.82, M = 0.4 + 0.06 * Math.sin(s * 0.35), h = _(s * 0.5, M, a, o, c), m = s * (0.5 + (1.7 - 0.5) * (t.scanMul ?? 1)), D = $(n, t.rsPow ?? 0.6), p = t.dimBase ?? 1, e = [], l = t.latRings ?? 17, R = t.lonDensity ?? 44; + for (let w = 0; w <= l; w++) { + const i = -Math.PI / 2 + w / l * Math.PI, u = Math.cos(i), y = Math.sin(i), b = Math.max(1, Math.round(Math.abs(u) * R)); + for (let f = 0; f < b; f++) { + const P = f / b * 2 * Math.PI, [x, g, d] = h(u * Math.cos(P), y, u * Math.sin(P)), v = (d + 1) / 2, k = et(P + s * 0.5, m), N = Math.exp(-(k * k) / 0.18) * Math.max(0, d); + e.push({ + x, + y: g, + z: d, + r: ((t.rBase ?? 0.6) + (t.rDepth ?? 1.7) * v + (t.rBoost ?? 1) * N) * D, + white: (t.inkFar ?? 0.62) - (t.inkSpan ?? 0.54) * v, + // dimBase < 1 fades un-scanned dots so the meridian reads clearly + a: p + (1 - p) * Math.min(1, N) + }); + } + } + return L(e, [], t.rMin); +}, dt = (n, s, t) => { + const r = n / 2, a = n / 2, o = n / 2 * 0.82, c = _(s * 0.55, 0.35 + 0.1 * Math.sin(s * 0.9), r, a, o), M = $(n, t.rsPow ?? 0.6), h = t.moveCount ?? 14, m = ut(h), D = lt(s, h, 0.42, 1.2), p = [], e = t.latRings ?? 15, l = t.lonDensity ?? 40; + for (let R = 0; R <= e; R++) { + const w = -Math.PI / 2 + R / e * Math.PI, i = Math.cos(w), u = Math.sin(w), y = Math.max(1, Math.round(Math.abs(i) * l)); + for (let b = 0; b < y; b++) { + const f = b / y * 2 * Math.PI, [P, x, g, d] = pt([i * Math.cos(f), u, i * Math.sin(f)], m, D), [v, k, N] = c(P, x, g), z = (N + 1) / 2; + p.push({ + x: v, + y: k, + z: N, + r: ((t.rBase ?? 0.6) + (t.rDepth ?? 1.7) * z + (d ? t.rActive ?? 0.3 : 0)) * M, + white: (t.inkFar ?? 0.62) - (t.inkSpan ?? 0.54) * z - (d ? 0.14 : 0) + }); + } + } + return L(p, [], t.rMin); +}, bt = (n, s, t) => { + const r = n / 2, a = n / 2, o = n / 2 * 0.874, c = _(s * 0.18, 0.38, r, a, 1), M = $(n, t.rsPow ?? 0.6), h = [], m = t.rings ?? 15, D = t.lonDensity ?? 40; + for (let p = 0; p <= m; p++) { + const e = -Math.PI / 2 + p / m * Math.PI, l = Math.cos(e), R = Math.sin(e), w = 0.62 * Math.sin(s * 2.1 - p * 0.52) + 0.38 * Math.sin(s * 1.27 + p * 0.83), i = o * (0.88 + 0.105 * w), u = Math.max(1, Math.round(Math.abs(l) * D)); + for (let y = 0; y < u; y++) { + const b = y / u * 2 * Math.PI, [f, P, x] = c(l * Math.cos(b) * i, R * i, l * Math.sin(b) * i), g = (x / o + 1) / 2, d = Math.max(0, w); + h.push({ + x: f, + y: P, + z: x, + r: ((t.rBase ?? 0.6) + (t.rDepth ?? 1.7) * g) * (1 + 0.4 * d) * M, + white: 0.66 - 0.56 * g - 0.1 * d + }); + } + } + return L(h, [], t.rMin); +}; +function xt(n) { + return n * n * (3 - 2 * n); +} +function st(n) { + const s = n.length, t = []; + let r = 0; + for (let a = 0; a < s; a++) { + const o = n[a], c = n[(a + 1) % s], M = Math.hypot(c[0] - o[0], c[1] - o[1]); + t.push(M), r += M; + } + return (a) => { + let o = a * r, c = 0; + for (; o > t[c] && c < s - 1; ) + o -= t[c], c++; + const M = n[c], h = n[(c + 1) % s], m = t[c] ? Math.min(1, o / t[c]) : 0; + return [M[0] + (h[0] - M[0]) * m, M[1] + (h[1] - M[1]) * m]; + }; +} +const yt = (n) => { + const s = -Math.PI / 2 + n * 2 * Math.PI; + return [Math.cos(s) * 0.24, Math.sin(s) * 0.24]; +}, gt = st([ + [0, -0.26], + [0.24, 0.16], + [-0.24, 0.16] +]), mt = st([ + [0, -0.2], + [0.2, -0.2], + [0.2, 0.2], + [-0.2, 0.2], + [-0.2, -0.2] +]), H = [yt, gt, mt]; +function wt(n) { + return Math.max(6, Math.round(34 * n)); +} +const V = 1.4, ot = 0.9, Q = V + ot, Pt = (n, s, t) => { + const r = H.length, a = s % (Q * r), o = Math.floor(a / Q), c = a - o * Q, M = c > V ? xt((c - V) / ot) : 0, h = t.spread ?? 1, m = H[o], D = H[(o + 1) % r], p = 160, e = []; + for (let x = 0; x < p; x++) { + const g = x / p, d = m(g), v = D(g); + e.push([(d[0] + (v[0] - d[0]) * M) * h, (d[1] + (v[1] - d[1]) * M) * h]); + } + const l = []; + let R = 0; + for (let x = 0; x < p; x++) { + const g = e[x], d = e[(x + 1) % p], v = Math.hypot(d[0] - g[0], d[1] - g[1]); + l.push(v), R += v; + } + const w = wt(t.iconD ?? 1), i = (t.rDot ?? 0.021) * 1.35 * h, u = 1 + 0.02 * Math.sin(c * 3.1), y = [], b = n / 2; + let f = 0, P = 0; + for (let x = 0; x < w; x++) { + const g = x / w * R; + for (; P + l[f] < g && f < p - 1; ) + P += l[f], f++; + const d = e[f], v = e[(f + 1) % p], k = l[f] ? Math.min(1, (g - P) / l[f]) : 0, N = (d[0] + (v[0] - d[0]) * k) * u, z = (d[1] + (v[1] - d[1]) * k) * u; + y.push({ + x: b + N * n, + y: b + z * n, + z: 0, + r: Math.max(0.35, i * n), + white: 0.1 + }); + } + return L(y, [], t.rMin); +}, Rt = (n, s, t) => { + const r = n / 2, a = n / 2, o = n / 2 * 0.82, c = _(s * 0.12, 0.3, r, a, 1), M = $(n, t.rsPow ?? 0.6), h = [], m = t.orbitN ?? 12, D = t.ghostN ?? 40, p = t.particles ?? 3; + for (let e = 0; e < m; e++) { + const l = E(e, 1.7), R = E(e, 5.2), w = E(e, 8.9), i = o * (0.45 + 0.52 * l), u = l * 2 * Math.PI, y = Math.acos(2 * R - 1), b = Math.sin(y) * Math.cos(u), f = Math.cos(y), P = Math.sin(y) * Math.sin(u); + let x = -f, g = b; + const d = 0, v = Math.max(1e-6, Math.sqrt(x * x + g * g)); + x /= v, g /= v; + const k = f * d - P * g, N = P * x - b * d, z = b * g - f * x, O = (0.25 + 0.55 * w) * (w > 0.5 ? 1 : -1); + for (let B = 0; B < D; B++) { + const I = B / D * 2 * Math.PI, [S, A, T] = c( + (x * Math.cos(I) + k * Math.sin(I)) * i, + (g * Math.cos(I) + N * Math.sin(I)) * i, + (d * Math.cos(I) + z * Math.sin(I)) * i + ), C = (T / i + 1) / 2; + h.push({ + x: S, + y: A, + z: T, + r: (t.ghostR ?? 0.9) * M, + white: 0.72, + a: (t.ghostA ?? 0.5) * (0.4 + 0.6 * C) + }); + } + for (let B = 0; B < p; B++) { + const I = s * O + B / p * 2 * Math.PI + R * 6, [S, A, T] = c( + (x * Math.cos(I) + k * Math.sin(I)) * i, + (g * Math.cos(I) + N * Math.sin(I)) * i, + (d * Math.cos(I) + z * Math.sin(I)) * i + ), C = (T / i + 1) / 2; + h.push({ + x: S, + y: A, + z: T, + r: ((t.partR ?? 1.2) + (t.partRDepth ?? 1.6) * C) * M, + white: 0.3 - 0.22 * C + }); + } + } + return L(h, [], t.rMin); +}, Z = (n, s, t) => { + const r = n / 2, a = n / 2, o = n / 2 * 0.78, c = t.spin ?? 1, M = 0.3, h = _(s * 0.1 * c, M, r, a, 1), m = $(n, t.rsPow ?? 0.6), D = [], p = t.ghostN ?? 150; + for (let z = 0; z < p; z++) { + const O = J(z, p), [B, I, S] = h(O[0] * o, O[1] * o, O[2] * o), A = (S / o + 1) / 2; + D.push({ x: B, y: I, z: S, r: 0.8 * m, white: 0.78, a: 0.1 + 0.22 * A }); + } + const e = s * 0.24 * c, l = t.faceOn ? -M : 0.55 + 0.3 * Math.sin(s * 0.18) * c, R = Math.cos(e), w = 0, i = Math.sin(e), u = -i * Math.sin(l), y = Math.cos(l), b = R * Math.sin(l), f = w * b - i * y, P = i * u - R * b, x = R * y - w * u, g = 0.23 * (t.wobMul ?? 1), d = t.faceOn ? o / (1 + 0.85 * g) : o, v = t.lanes ?? 5, k = t.segs ?? 88, N = Math.max(1, Math.round(v * (t.bandMul ?? 1))); + for (let z = 0; z < N; z++) { + const O = (z - (N - 1) / 2) * 0.075, B = Math.abs(z - (N - 1) / 2) / Math.max(1, (N - 1) / 2); + for (let I = 0; I < k; I++) { + const S = I / k * 2 * Math.PI, A = (0.16 * Math.sin(S * 3 - s * 1.7 + z * 0.22) + 0.07 * Math.sin(S * 5 + s * 1.1)) * (t.wobMul ?? 1), T = t.faceOn ? 1 + A : 1, C = t.faceOn ? O : O + A, q = R * Math.cos(S) + u * Math.sin(S) + f * C, F = w * Math.cos(S) + y * Math.sin(S) + P * C, j = i * Math.cos(S) + b * Math.sin(S) + x * C, W = Math.sqrt(q * q + F * F + j * j), Y = d * T, [ct, at, X] = h(q / W * Y, F / W * Y, j / W * Y), K = (X / o + 1) / 2; + D.push({ + x: ct, + y: at, + z: X, + r: ((t.rBase ?? 1.1) + (t.rDepth ?? 1.7) * K) * (1 - 0.25 * B) * m, + white: 0.52 - 0.44 * K + 0.18 * B, + a: 0.4 + 0.6 * K + }); + } + } + return L(D, [], t.rMin); +}, Dt = (n, s, t) => { + const r = n / 2, a = n / 2, o = n / 2 * 0.8 * (t.spread ?? 1), c = _(s * 0.12, 0.32, r, a, o), M = $(n, t.rsPow ?? 0.6), h = t.nodeN ?? 30, m = t.thr ?? 0.72, D = t.nodeR ?? 1.4, p = t.nodeRDepth ?? 1.8, e = []; + for (let i = 0; i < h; i++) { + const u = J(i, h), y = u[0] + 0.3 * (G(i * 0.31 + 9, s * 0.24) - 0.5) * 2, b = u[1] + 0.3 * (G(i * 0.53 + 27, s * 0.21) - 0.5) * 2, f = u[2] + 0.3 * (G(i * 0.77 + 55, s * 0.27) - 0.5) * 2, P = Math.sqrt(y * y + b * b + f * f); + e.push([y / P, b / P, f / P]); + } + const l = [], R = []; + for (let i = 0; i < h; i++) + for (let u = i + 1; u < h; u++) { + const y = e[i][0] - e[u][0], b = e[i][1] - e[u][1], f = e[i][2] - e[u][2], P = Math.sqrt(y * y + b * b + f * f); + if (P >= m) continue; + const [x, g, d] = c(e[i][0], e[i][1], e[i][2]), [v, k, N] = c(e[u][0], e[u][1], e[u][2]), z = ((d + N) / 2 + 1) / 2; + l.push({ + x1: x, + y1: g, + x2: v, + y2: k, + white: 0.42, + a: (1 - P / m) * (0.3 + 0.55 * z), + w: Math.max(0.6, (t.lineW ?? 0.8) * M) + }); + } + for (let i = 0; i < h; i++) { + const [u, y, b] = c(e[i][0], e[i][1], e[i][2]), f = (b + 1) / 2, P = 1 + 0.25 * Math.sin(s * 1.4 + i * 2.7); + R.push({ + x: u, + y, + z: b, + r: (D + p * f) * P * M, + white: 0.55 - 0.45 * f + }); + } + const w = t.signals ?? 5; + for (let i = 0; i < w; i++) { + const u = Math.floor(s * 0.55 + i * 7.31), y = Math.floor(E(u, i * 3.1 + 1.7) * h), b = Math.floor(E(u, i * 5.7 + 4.2) * h); + if (y === b) continue; + const f = nt(s * 0.55 + i * 7.31), P = U(e[y][0], e[b][0], f), x = U(e[y][1], e[b][1], f), g = U(e[y][2], e[b][2], f), d = Math.max(1e-6, Math.sqrt(P * P + x * x + g * g)), [v, k, N] = c(P / d, x / d, g / d), z = (N + 1) / 2; + R.push({ + x: v, + y: k, + z: N, + r: (D * 1.5 + p * z) * M, + white: 0.05, + a: 0.5 + 0.5 * z + }); + } + return L(R, l, t.rMin); +}, vt = { + orbits: Rt, + globe: ft, + rubik: dt, + wave: bt, + web: Dt, + braid: Mt, + ribbon: Z, + // ring shares ribbon's geometry — the `faceOn` profile flag switches it + ring: Z, + morph: Pt +}, Ct = Object.fromEntries( + Object.entries(vt).map(([n, s]) => [ + n, + (t, r, a, o, c) => ht(t, s(r, a, c), o) + ]) +), zt = [ + ["latRings", "lonDensity"], + ["rings", "lonDensity"], + ["lanes", "segs"] +], Nt = ["orbitN", "ghostN", "nodeN", "strandN", "signals"], It = ["iconD"], kt = [ + "rBase", + "rDepth", + "rActive", + "rDot", + "ghostR", + "partR", + "partRDepth", + "nodeR", + "nodeRDepth" +]; +function St(n, s) { + const t = { ...n }, r = /* @__PURE__ */ new Set(), a = Math.sqrt(s); + for (const [o, c] of zt) { + const M = t[o], h = t[c]; + M != null && h != null && !r.has(o) && !r.has(c) && (t[o] = Math.max(2, Math.round(M * a)), t[c] = Math.max(2, Math.round(h * a)), r.add(o), r.add(c)); + } + for (const o of Nt) { + const c = t[o]; + c != null && c !== 0 && !r.has(o) && (t[o] = Math.max(1, Math.round(c * s))); + } + for (const o of It) { + const c = t[o]; + c != null && (t[o] = Math.max(0.02, c * s)); + } + return t; +} +function Bt(n, s) { + const t = { ...n }; + for (const r of kt) { + const a = t[r]; + a != null && (t[r] = a * s); + } + return t.rSizeMul = (t.rSizeMul ?? 1) * s, t; +} +const Et = { + globe: { + latRings: 17, + lonDensity: 44, + rBase: 0.6, + rDepth: 1.7, + rBoost: 1, + inkFar: 0.62, + inkSpan: 0.54, + rsPow: 0.6, + rMin: 0.3 + }, + orbits: { + orbitN: 12, + ghostN: 40, + ghostR: 0.9, + ghostA: 0.5, + particles: 3, + partR: 1.2, + partRDepth: 1.6, + rsPow: 0.6, + rMin: 0.3 + }, + rubik: { + latRings: 15, + lonDensity: 40, + moveCount: 14, + rBase: 0.6, + rDepth: 1.7, + rActive: 0.3, + inkFar: 0.62, + inkSpan: 0.54, + rsPow: 0.6, + rMin: 0.3 + }, + wave: { + rings: 15, + lonDensity: 40, + rBase: 0.6, + rDepth: 1.7, + rsPow: 0.6, + rMin: 0.3 + }, + web: { + nodeN: 30, + thr: 0.72, + signals: 5, + nodeR: 1.4, + nodeRDepth: 1.8, + lineW: 0.8, + rsPow: 0.6, + rMin: 0.3 + }, + braid: { + strandN: 52, + turns: 3, + ghostN: 150, + rBase: 1.2, + rDepth: 1.8, + rsPow: 0.6, + rMin: 0.3 + }, + ribbon: { + lanes: 5, + segs: 88, + ghostN: 150, + rBase: 1.1, + rDepth: 1.7, + rsPow: 0.6, + rMin: 0.3 + }, + // ring shares ribbon's painter; faceOn cancels the camera tilt and moves + // the undulation onto the radius, and there is no ghost sphere behind it + ring: { + lanes: 5, + segs: 88, + ghostN: 0, + faceOn: 1, + rBase: 1.1, + rDepth: 1.7, + rsPow: 0.6, + rMin: 0.3 + }, + morph: { + rDot: 0.021, + iconD: 1, + rMin: 0.25 + } +}, Ot = { + working: "orbits", + searching: "globe", + solving: "rubik", + listening: "wave", + connecting: "web", + weaving: "braid", + composing: "ribbon", + breathing: "ring", + shaping: "morph" +}, At = { + orbits: { + 64: { speed: 1.885, count: 1, size: 1 }, + 20: { speed: 3.9, count: 0.238, size: 2.4 } + }, + globe: { + 64: { speed: 2.015, count: 0.42, size: 1.15, extra: { scanMul: 4.08, dimBase: 0.45 } }, + 20: { speed: 2.665, count: 0.105, size: 1.75, extra: { scanMul: 4.335, dimBase: 0.45 } } + }, + rubik: { + 64: { speed: 1.82, count: 0.35, size: 1.05 }, + 20: { speed: 1.95, count: 0.088, size: 1.9 } + }, + wave: { + 64: { speed: 4.388, count: 0.341, size: 1 }, + 20: { speed: 3.998, count: 0.105, size: 1.6 } + }, + web: { + 64: { speed: 3.315, count: 1.35, size: 0.95 }, + 20: { speed: 6.63, count: 0.25, size: 1.52 } + }, + braid: { + 64: { speed: 1.625, count: 0.5, size: 1 }, + 20: { speed: 2.75, count: 0.1125, size: 1.36 } + }, + ribbon: { + 64: { speed: 2.34, count: 0.25, size: 0.85, extra: { spin: 0, bandMul: 3.9, wobMul: 1 } }, + 20: { speed: 3.12, count: 0.051, size: 1.073, extra: { spin: 0, bandMul: 4.94, wobMul: 1 } } + }, + ring: { + 64: { speed: 3.24, count: 0.25, size: 0.956, extra: { spin: 0, bandMul: 3.627, wobMul: 0.368 } }, + 20: { speed: 3.78, count: 0.028, size: 1.622, extra: { spin: 0, bandMul: 3.968, wobMul: 0.565 } } + }, + morph: { + 64: { speed: 2.405, count: 0.702, size: 0.395, extra: { spread: 1.45 } }, + 20: { speed: 2.08, count: 0.53, size: 1.011, extra: { spread: 1.45 } } + } +}, tt = /* @__PURE__ */ new Map(); +function Lt(n, s) { + const t = `${n}-${s}`, r = tt.get(t); + if (r) return r; + const a = Ot[n], o = At[a][s]; + let c = { ...Et[a] }; + o.count !== 1 && (c = St(c, o.count)), o.size !== 1 && (c = Bt(c, o.size)), o.extra && (c = { ...c, ...o.extra }); + const M = { mode: a, speed: o.speed, opts: c }; + return tt.set(t, M), M; +} +export { + Ct as MODE_DRAWS, + vt as MODE_FRAMES, + Ot as STATE_TO_MODE, + L as finalizeFrame, + _ as makeProj, + rt as paint, + ht as paintFrame, + it as paintLines, + $ as radiusScale, + Lt as resolvePreset +};