mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-15 17:25:26 -04:00
Odysseus v1.0
This commit is contained in:
@@ -0,0 +1,406 @@
|
||||
// static/js/spinner.js
|
||||
|
||||
/**
|
||||
* ASCII Spinner Module for AI thinking/processing status
|
||||
*/
|
||||
|
||||
class Spinner {
|
||||
constructor(message = "AI is processing", style = "right", animation = "spinner") {
|
||||
// Different animation frames
|
||||
this.animations = {
|
||||
spinner: ['|', '/', '-', '\\'],
|
||||
wave: ['▁▂▃', '▂▃▄', '▃▄▅', '▄▅▆', '▅▆▅', '▆▅▄', '▅▄▃', '▄▃▂', '▃▂▁']
|
||||
};
|
||||
|
||||
this.animation = animation;
|
||||
this.frames = this.animations[animation] || this.animations.spinner;
|
||||
this.message = message;
|
||||
this.style = style; // "left", "right", or "clean"
|
||||
this.isRunning = false;
|
||||
this.currentFrame = 0;
|
||||
this.intervalId = null;
|
||||
this.rafId = null;
|
||||
this.element = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and return the spinner HTML element
|
||||
*/
|
||||
createElement() {
|
||||
if (this.animation === 'sinewave') {
|
||||
return this._createSineWaveElement();
|
||||
}
|
||||
if (this.animation === 'whirlpool') {
|
||||
return this._createWhirlpoolElement();
|
||||
}
|
||||
const span = document.createElement('span');
|
||||
span.className = 'ai-spinner';
|
||||
span.style.cssText = 'font-family: monospace; white-space: pre;';
|
||||
this.element = span;
|
||||
this.updateDisplay();
|
||||
return span;
|
||||
}
|
||||
|
||||
_createSineWaveElement() {
|
||||
const wrapper = document.createElement('span');
|
||||
wrapper.className = 'ai-spinner ai-spinner-sinewave';
|
||||
wrapper.style.cssText = 'font-family: monospace; white-space: pre; display: inline-flex; align-items: center; gap: 6px;';
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 50;
|
||||
canvas.height = 18;
|
||||
canvas.style.cssText = 'display: inline-block; vertical-align: middle;';
|
||||
|
||||
const msgSpan = document.createElement('span');
|
||||
msgSpan.textContent = this.message;
|
||||
this._msgSpan = msgSpan;
|
||||
|
||||
if (this.style === 'left') {
|
||||
wrapper.appendChild(canvas);
|
||||
wrapper.appendChild(msgSpan);
|
||||
} else if (this.style === 'right') {
|
||||
wrapper.appendChild(msgSpan);
|
||||
wrapper.appendChild(canvas);
|
||||
} else {
|
||||
wrapper.appendChild(msgSpan);
|
||||
}
|
||||
|
||||
this._canvas = canvas;
|
||||
this._ctx = canvas.getContext('2d');
|
||||
this._waveT = 0;
|
||||
this._wavePrev = performance.now();
|
||||
this.element = wrapper;
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
_drawSineWave() {
|
||||
const ctx = this._ctx;
|
||||
const W = this._canvas.width;
|
||||
const H = this._canvas.height;
|
||||
const midY = H / 2;
|
||||
const AMP = 7;
|
||||
const CYCLES = 2.5;
|
||||
const PAD = 3;
|
||||
const trackW = W - 2 * PAD;
|
||||
const BASE_SPEED = 0.44;
|
||||
const MIN_SPEED = 0.4;
|
||||
const MAX_SPEED = 2.5;
|
||||
|
||||
const now = performance.now();
|
||||
const dt = (now - this._wavePrev) / 1000;
|
||||
this._wavePrev = now;
|
||||
|
||||
const dotPhase = 0.5 * CYCLES * 2 * Math.PI + this._waveT;
|
||||
const norm = (1 + Math.sin(dotPhase)) / 2;
|
||||
const speedMul = MIN_SPEED + (MAX_SPEED - MIN_SPEED) * Math.pow(norm, 1.3);
|
||||
this._waveT += dt * BASE_SPEED * speedMul * CYCLES * 2 * Math.PI;
|
||||
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
// wave line
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i <= 80; i++) {
|
||||
const frac = i / 80;
|
||||
const x = PAD + frac * trackW;
|
||||
const phase = frac * CYCLES * 2 * Math.PI + this._waveT;
|
||||
const y = midY + Math.sin(phase) * AMP;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.strokeStyle = 'rgba(156, 222, 242, 0.5)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// dot
|
||||
const cx = W / 2;
|
||||
const cPhase = 0.5 * CYCLES * 2 * Math.PI + this._waveT;
|
||||
const cy = midY + Math.sin(cPhase) * AMP;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, 1.5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(156, 222, 242, 0.9)';
|
||||
ctx.fill();
|
||||
|
||||
if (this.isRunning) {
|
||||
this.rafId = requestAnimationFrame(() => this._drawSineWave());
|
||||
}
|
||||
}
|
||||
|
||||
_createWhirlpoolElement() {
|
||||
const wrapper = document.createElement('span');
|
||||
wrapper.className = 'ai-spinner ai-spinner-whirlpool';
|
||||
wrapper.style.cssText = 'font-family: monospace; white-space: pre; display: inline-flex; align-items: center; gap: 6px;';
|
||||
|
||||
const size = this._wpSize || 18;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
canvas.style.cssText = 'display: inline-block; vertical-align: middle;';
|
||||
|
||||
const msgSpan = document.createElement('span');
|
||||
msgSpan.textContent = this.message;
|
||||
this._msgSpan = msgSpan;
|
||||
|
||||
if (this.style === 'left') {
|
||||
wrapper.appendChild(canvas);
|
||||
wrapper.appendChild(msgSpan);
|
||||
} else if (this.style === 'right') {
|
||||
wrapper.appendChild(msgSpan);
|
||||
wrapper.appendChild(canvas);
|
||||
} else {
|
||||
wrapper.appendChild(canvas);
|
||||
}
|
||||
|
||||
this._wpCanvas = canvas;
|
||||
this._wpCtx = canvas.getContext('2d');
|
||||
this._wpFrame = 60;
|
||||
this.element = wrapper;
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
_drawWhirlpool() {
|
||||
const ctx = this._wpCtx;
|
||||
const W = this._wpCanvas.width;
|
||||
const H = this._wpCanvas.height;
|
||||
const cx = W / 2, cy = H / 2;
|
||||
const maxR = Math.min(W, H) / 2 - 1;
|
||||
const lw = W > 30 ? 3 : W > 20 ? 2 : 1.5;
|
||||
const TOTAL_TURNS = 4;
|
||||
const TAIL_LEN = 0.45;
|
||||
const SPIN_SPEED = 0.08;
|
||||
const LAYERS = 12;
|
||||
const STEPS = 50;
|
||||
const t = this._wpFrame;
|
||||
|
||||
// Colors from CSS vars — read ONCE and cache. Calling getComputedStyle every
|
||||
// frame forces a full style recalc per frame, which janks/freezes the canvas
|
||||
// animation badly when it's painting over a heavy photo. (Theme changes are
|
||||
// rare; the spinner is short-lived, so a stale cache is fine.)
|
||||
if (!this._wpColors) {
|
||||
const s = getComputedStyle(document.documentElement);
|
||||
this._wpColors = {
|
||||
fg: s.getPropertyValue('--red').trim() || s.getPropertyValue('--fg').trim() || '#9cdef2',
|
||||
track: s.getPropertyValue('--border').trim() || '#355a66',
|
||||
};
|
||||
}
|
||||
const fg = this._wpColors.fg;
|
||||
const track = this._wpColors.track;
|
||||
|
||||
function spiralPoint(frac, rot) {
|
||||
const r = maxR * (1 - frac);
|
||||
const angle = frac * TOTAL_TURNS * Math.PI * 2 + rot;
|
||||
return { x: cx + Math.cos(angle) * r, y: cy + Math.sin(angle) * r };
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
// track ring
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, maxR - lw / 2, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = track;
|
||||
ctx.lineWidth = lw;
|
||||
ctx.globalAlpha = 0.35;
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
const headPos = (t * 0.008) % 1;
|
||||
|
||||
// overlapping sub-paths for smooth fade
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
for (let layer = LAYERS - 1; layer >= 0; layer--) {
|
||||
const endFrac = (layer + 1) / LAYERS;
|
||||
const stepsForLayer = Math.ceil(STEPS * endFrac);
|
||||
const alpha = Math.pow(1 - endFrac, 2) * 0.7;
|
||||
|
||||
ctx.beginPath();
|
||||
let started = false;
|
||||
let prevPos = -1;
|
||||
for (let i = 0; i <= stepsForLayer; i++) {
|
||||
const frac = i / STEPS;
|
||||
let pos = headPos - frac * TAIL_LEN;
|
||||
if (pos < 0) pos += 1;
|
||||
if (started && prevPos < 0.3 && pos > 0.7) {
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
started = false;
|
||||
}
|
||||
const pt = spiralPoint(pos, t * SPIN_SPEED);
|
||||
if (!started) { ctx.moveTo(pt.x, pt.y); started = true; }
|
||||
else ctx.lineTo(pt.x, pt.y);
|
||||
prevPos = pos;
|
||||
}
|
||||
ctx.strokeStyle = fg;
|
||||
ctx.lineWidth = lw * 0.8;
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// bright dot at head
|
||||
const head = spiralPoint(headPos, t * SPIN_SPEED);
|
||||
ctx.beginPath();
|
||||
ctx.arc(head.x, head.y, Math.max(1, lw * 0.45), 0, Math.PI * 2);
|
||||
ctx.fillStyle = fg;
|
||||
ctx.globalAlpha = 0.9;
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
this._wpFrame++;
|
||||
if (!this.isRunning) return;
|
||||
// Leak-safe self-terminate: stop once our element WAS in the DOM and then
|
||||
// got removed (e.g. a loading row replaced by results). But keep spinning
|
||||
// before it's first appended — start() runs synchronously, before the
|
||||
// caller inserts the element, so it isn't connected on frame 1.
|
||||
const connected = !!(this.element && this.element.isConnected);
|
||||
if (connected) this._wpWasConnected = true;
|
||||
if (connected || !this._wpWasConnected) {
|
||||
this.rafId = requestAnimationFrame(() => this._drawWhirlpool());
|
||||
} else {
|
||||
this.isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the spinner display
|
||||
*/
|
||||
updateDisplay() {
|
||||
if (!this.element) return;
|
||||
|
||||
const frame = this.frames[this.currentFrame % this.frames.length];
|
||||
|
||||
let display = '';
|
||||
if (this.style === "left") {
|
||||
display = `${frame} ${this.message}`;
|
||||
} else if (this.style === "right") {
|
||||
display = `${this.message} ${frame}`;
|
||||
} else { // clean
|
||||
display = this.message;
|
||||
}
|
||||
|
||||
this.element.innerHTML = display;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the spinner animation
|
||||
*/
|
||||
start(speed = 150) {
|
||||
if (this.isRunning) return;
|
||||
this.isRunning = true;
|
||||
|
||||
if (this.animation === 'sinewave') {
|
||||
this._wavePrev = performance.now();
|
||||
this._drawSineWave();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.animation === 'whirlpool') {
|
||||
this._wpFrame = 60;
|
||||
this._drawWhirlpool();
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentFrame = 0;
|
||||
this.intervalId = setInterval(() => {
|
||||
this.currentFrame++;
|
||||
this.updateDisplay();
|
||||
}, speed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the spinner
|
||||
*/
|
||||
stop() {
|
||||
this.isRunning = false;
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
if (this.rafId) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the message while spinner is running
|
||||
*/
|
||||
updateMessage(newMessage) {
|
||||
this.message = newMessage;
|
||||
if ((this.animation === 'sinewave' || this.animation === 'whirlpool') && this._msgSpan) {
|
||||
this._msgSpan.textContent = newMessage;
|
||||
} else {
|
||||
this.updateDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the spinner label text
|
||||
*/
|
||||
updateLabel(newMessage) {
|
||||
this.message = newMessage;
|
||||
if (this._msgSpan) {
|
||||
this._msgSpan.textContent = newMessage;
|
||||
} else {
|
||||
this.updateDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the spinner and clean up
|
||||
*/
|
||||
destroy() {
|
||||
this.stop();
|
||||
if (this.element && this.element.parentNode) {
|
||||
this.element.parentNode.removeChild(this.element);
|
||||
}
|
||||
this.element = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new spinner instance
|
||||
*/
|
||||
export function create(message, style = "right", animation = "wave") {
|
||||
return new Spinner(message, style, animation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a standalone whirlpool circle spinner (replaces CSS .spinner)
|
||||
* Returns { element, start(), stop(), destroy() }
|
||||
*/
|
||||
export function createWhirlpool(size = 24) {
|
||||
const sp = new Spinner('', 'clean', 'whirlpool');
|
||||
sp._wpSize = size;
|
||||
const el = sp.createElement();
|
||||
// wrap in a div matching .spinner layout
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'spinner-whirlpool';
|
||||
wrap.style.cssText = `width:${size}px;height:${size}px;margin:8px auto;`;
|
||||
wrap.appendChild(el);
|
||||
sp.start();
|
||||
return { element: wrap, stop: () => sp.stop(), destroy: () => sp.destroy() };
|
||||
}
|
||||
|
||||
/**
|
||||
* A consistent inline loading row for list/library empty-states: a label plus
|
||||
* the whirlpool spinner. Returns a detached element; the spinner self-stops
|
||||
* once the element leaves the DOM (see _drawWhirlpool), so callers can just
|
||||
* replace it with results — no manual cleanup needed.
|
||||
*/
|
||||
export function createLoadingRow(text = 'Loading…', size = 16) {
|
||||
const sp = new Spinner('', 'clean', 'whirlpool');
|
||||
sp._wpSize = size;
|
||||
const canvas = sp.createElement();
|
||||
const row = document.createElement('div');
|
||||
row.className = 'lib-loading-row';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = text;
|
||||
row.appendChild(label);
|
||||
row.appendChild(canvas);
|
||||
sp.start();
|
||||
return row;
|
||||
}
|
||||
|
||||
export { Spinner };
|
||||
|
||||
const spinnerModule = { create, createWhirlpool, createLoadingRow, Spinner };
|
||||
export default spinnerModule;
|
||||
Reference in New Issue
Block a user