PageSourceSearch

https://jayreddinjr-jr-odysseus-hf.hf.space/static/js/spinner.js

js jayreddinjr-jr-odysseus-hf.hf.space collected 2026-10-04 16:59:14 UTC 11,916 bytes, 406 lines download raw bytes

1// static/js/spinner.js
2
3/**
4 * ASCII Spinner Module for AI thinking/processing status
5 */
6
7class Spinner {
8  constructor(message = "AI is processing", style = "right", animation = "spinner") {
9    // Different animation frames
10    this.animations = {
11      spinner: ['|', '/', '-', '\\'],
12      wave: ['▁▂▃', '▂▃▄', '▃▄▅', '▄▅▆', '▅▆▅', '▆▅▄', '▅▄▃', '▄▃▂', '▃▂▁']
13    };
14
15    this.animation = animation;
16    this.frames = this.animations[animation] || this.animations.spinner;
17    this.message = message;
18    this.style = style; // "left", "right", or "clean"
19    this.isRunning = false;
20    this.currentFrame = 0;
21    this.intervalId = null;
22    this.rafId = null;
23    this.element = null;
24  }
25
26  /**
27   * Create and return the spinner HTML element
28   */
29  createElement() {
30    if (this.animation === 'sinewave') {
31      return this._createSineWaveElement();
32    }
33    if (this.animation === 'whirlpool') {
34      return this._createWhirlpoolElement();
35    }
36    const span = document.createElement('span');
37    span.className = 'ai-spinner';
38    span.style.cssText = 'font-family: monospace; white-space: pre;';
39    this.element = span;
40    this.updateDisplay();
41    return span;
42  }
43
44  _createSineWaveElement() {
45    const wrapper = document.createElement('span');
46    wrapper.className = 'ai-spinner ai-spinner-sinewave';
47    wrapper.style.cssText = 'font-family: monospace; white-space: pre; display: inline-flex; align-items: center; gap: 6px;';
48
49    const canvas = document.createElement('canvas');
50    canvas.width = 50;
51    canvas.height = 18;
52    canvas.style.cssText = 'display: inline-block; vertical-align: middle;';
53
54    const msgSpan = document.createElement('span');
55    msgSpan.textContent = this.message;
56    this._msgSpan = msgSpan;
57
58    if (this.style === 'left') {
59      wrapper.appendChild(canvas);
60      wrapper.appendChild(msgSpan);
61    } else if (this.style === 'right') {
62      wrapper.appendChild(msgSpan);
63      wrapper.appendChild(canvas);
64    } else {
65      wrapper.appendChild(msgSpan);
66    }
67
68    this._canvas = canvas;
69    this._ctx = canvas.getContext('2d');
70    this._waveT = 0;
71    this._wavePrev = performance.now();
72    this.element = wrapper;
73    return wrapper;
74  }
75
76  _drawSineWave() {
77    const ctx = this._ctx;
78    const W = this._canvas.width;
79    const H = this._canvas.height;
80    const midY = H / 2;
81    const AMP = 7;
82    const CYCLES = 2.5;
83    const PAD = 3;
84    const trackW = W - 2 * PAD;
85    const BASE_SPEED = 0.44;
86    const MIN_SPEED = 0.4;
87    const MAX_SPEED = 2.5;
88
89    const now = performance.now();
90    const dt = (now - this._wavePrev) / 1000;
91    this._wavePrev = now;
92
93    const dotPhase = 0.5 * CYCLES * 2 * Math.PI + this._waveT;
94    const norm = (1 + Math.sin(dotPhase)) / 2;
95    const speedMul = MIN_SPEED + (MAX_SPEED - MIN_SPEED) * Math.pow(norm, 1.3);
96    this._waveT += dt * BASE_SPEED * speedMul * CYCLES * 2 * Math.PI;
97
98    ctx.clearRect(0, 0, W, H);
99
100    // wave line
101    ctx.beginPath();
102    for (let i = 0; i <= 80; i++) {
103      const frac = i / 80;
104      const x = PAD + frac * trackW;
105      const phase = frac * CYCLES * 2 * Math.PI + this._waveT;
106      const y = midY + Math.sin(phase) * AMP;
107      if (i === 0) ctx.moveTo(x, y);
108      else ctx.lineTo(x, y);
109    }
110    ctx.strokeStyle = 'rgba(156, 222, 242, 0.5)';
111    ctx.lineWidth = 1.5;
112    ctx.stroke();
113
114    // dot
115    const cx = W / 2;
116    const cPhase = 0.5 * CYCLES * 2 * Math.PI + this._waveT;
117    const cy = midY + Math.sin(cPhase) * AMP;
118    ctx.beginPath();
119    ctx.arc(cx, cy, 1.5, 0, Math.PI * 2);
120    ctx.fillStyle = 'rgba(156, 222, 242, 0.9)';
121    ctx.fill();
122
123    if (this.isRunning) {
124      this.rafId = requestAnimationFrame(() => this._drawSineWave());
125    }
126  }
127
128  _createWhirlpoolElement() {
129    const wrapper = document.createElement('span');
130    wrapper.className = 'ai-spinner ai-spinner-whirlpool';
131    wrapper.style.cssText = 'font-family: monospace; white-space: pre; display: inline-flex; align-items: center; gap: 6px;';
132
133    const size = this._wpSize || 18;
134    const canvas = document.createElement('canvas');
135    canvas.width = size;
136    canvas.height = size;
137    canvas.style.cssText = 'display: inline-block; vertical-align: middle;';
138
139    const msgSpan = document.createElement('span');
140    msgSpan.textContent = this.message;
141    this._msgSpan = msgSpan;
142
143    if (this.style === 'left') {
144      wrapper.appendChild(canvas);
145      wrapper.appendChild(msgSpan);
146    } else if (this.style === 'right') {
147      wrapper.appendChild(msgSpan);
148      wrapper.appendChild(canvas);
149    } else {
150      wrapper.appendChild(canvas);
151    }
152
153    this._wpCanvas = canvas;
154    this._wpCtx = canvas.getContext('2d');
155    this._wpFrame = 60;
156    this.element = wrapper;
157    return wrapper;
158  }
159
160  _drawWhirlpool() {
161    const ctx = this._wpCtx;
162    const W = this._wpCanvas.width;
163    const H = this._wpCanvas.height;
164    const cx = W / 2, cy = H / 2;
165    const maxR = Math.min(W, H) / 2 - 1;
166    const lw = W > 30 ? 3 : W > 20 ? 2 : 1.5;
167    const TOTAL_TURNS = 4;
168    const TAIL_LEN = 0.45;
169    const SPIN_SPEED = 0.08;
170    const LAYERS = 12;
171    const STEPS = 50;
172    const t = this._wpFrame;
173
174    // Colors from CSS vars — read ONCE and cache. Calling getComputedStyle every
175    // frame forces a full style recalc per frame, which janks/freezes the canvas
176    // animation badly when it's painting over a heavy photo. (Theme changes are
177    // rare; the spinner is short-lived, so a stale cache is fine.)
178    if (!this._wpColors) {
179      const s = getComputedStyle(document.documentElement);
180      this._wpColors = {
181        fg: s.getPropertyValue('--red').trim() || s.getPropertyValue('--fg').trim() || '#9cdef2',
182        track: s.getPropertyValue('--border').trim() || '#355a66',
183      };
184    }
185    const fg = this._wpColors.fg;
186    const track = this._wpColors.track;
187
188    function spiralPoint(frac, rot) {
189      const r = maxR * (1 - frac);
190      const angle = frac * TOTAL_TURNS * Math.PI * 2 + rot;
191      return { x: cx + Math.cos(angle) * r, y: cy + Math.sin(angle) * r };
192    }
193
194    ctx.clearRect(0, 0, W, H);
195
196    // track ring
197    ctx.beginPath();
198    ctx.arc(cx, cy, maxR - lw / 2, 0, Math.PI * 2);
199    ctx.strokeStyle = track;
200    ctx.lineWidth = lw;
201    ctx.globalAlpha = 0.35;
202    ctx.stroke();
203    ctx.globalAlpha = 1;
204
205    const headPos = (t * 0.008) % 1;
206
207    // overlapping sub-paths for smooth fade
208    ctx.lineCap = 'round';
209    ctx.lineJoin = 'round';
210    for (let layer = LAYERS - 1; layer >= 0; layer--) {
211      const endFrac = (layer + 1) / LAYERS;
212      const stepsForLayer = Math.ceil(STEPS * endFrac);
213      const alpha = Math.pow(1 - endFrac, 2) * 0.7;
214
215      ctx.beginPath();
216      let started = false;
217      let prevPos = -1;
218      for (let i = 0; i <= stepsForLayer; i++) {
219        const frac = i / STEPS;
220        let pos = headPos - frac * TAIL_LEN;
221        if (pos < 0) pos += 1;
222        if (started && prevPos < 0.3 && pos > 0.7) {
223          ctx.stroke();
224          ctx.beginPath();
225          started = false;
226        }
227        const pt = spiralPoint(pos, t * SPIN_SPEED);
228        if (!started) { ctx.moveTo(pt.x, pt.y); started = true; }
229        else ctx.lineTo(pt.x, pt.y);
230        prevPos = pos;
231      }
232      ctx.strokeStyle = fg;
233      ctx.lineWidth = lw * 0.8;
234      ctx.globalAlpha = alpha;
235      ctx.stroke();
236    }
237
238    // bright dot at head
239    const head = spiralPoint(headPos, t * SPIN_SPEED);
240    ctx.beginPath();
241    ctx.arc(head.x, head.y, Math.max(1, lw * 0.45), 0, Math.PI * 2);
242    ctx.fillStyle = fg;
243    ctx.globalAlpha = 0.9;
244    ctx.fill();
245    ctx.globalAlpha = 1;
246
247    this._wpFrame++;
248    if (!this.isRunning) return;
249    // Leak-safe self-terminate: stop once our element WAS in the DOM and then
250    // got removed (e.g. a loading row replaced by results). But keep spinning
251    // before it's first appended — start() runs synchronously, before the
252    // caller inserts the element, so it isn't connected on frame 1.
253    const connected = !!(this.element && this.element.isConnected);
254    if (connected) this._wpWasConnected = true;
255    if (connected || !this._wpWasConnected) {
256      this.rafId = requestAnimationFrame(() => this._drawWhirlpool());
257    } else {
258      this.isRunning = false;
259    }
260  }
261
262  /**
263   * Update the spinner display
264   */
265  updateDisplay() {
266    if (!this.element) return;
267
268    const frame = this.frames[this.currentFrame % this.frames.length];
269
270    let display = '';
271    if (this.style === "left") {
272      display = `${frame} ${this.message}`;
273    } else if (this.style === "right") {
274      display = `${this.message} ${frame}`;
275    } else { // clean
276      display = this.message;
277    }
278
279    this.element.innerHTML = display;
280  }
281
282  /**
283   * Start the spinner animation
284   */
285  start(speed = 150) {
286    if (this.isRunning) return;
287    this.isRunning = true;
288
289    if (this.animation === 'sinewave') {
290      this._wavePrev = performance.now();
291      this._drawSineWave();
292      return;
293    }
294
295    if (this.animation === 'whirlpool') {
296      this._wpFrame = 60;
297      this._drawWhirlpool();
298      return;
299    }
300
301    this.currentFrame = 0;
302    this.intervalId = setInterval(() => {
303      this.currentFrame++;
304      this.updateDisplay();
305    }, speed);
306  }
307
308  /**
309   * Stop the spinner
310   */
311  stop() {
312    this.isRunning = false;
313    if (this.intervalId) {
314      clearInterval(this.intervalId);
315      this.intervalId = null;
316    }
317    if (this.rafId) {
318      cancelAnimationFrame(this.rafId);
319      this.rafId = null;
320    }
321  }
322
323  /**
324   * Update the message while spinner is running
325   */
326  updateMessage(newMessage) {
327    this.message = newMessage;
328    if ((this.animation === 'sinewave' || this.animation === 'whirlpool') && this._msgSpan) {
329      this._msgSpan.textContent = newMessage;
330    } else {
331      this.updateDisplay();
332    }
333  }
334
335  /**
336   * Update the spinner label text
337   */
338  updateLabel(newMessage) {
339    this.message = newMessage;
340    if (this._msgSpan) {
341      this._msgSpan.textContent = newMessage;
342    } else {
343      this.updateDisplay();
344    }
345  }
346
347  /**
348   * Destroy the spinner and clean up
349   */
350  destroy() {
351    this.stop();
352    if (this.element && this.element.parentNode) {
353      this.element.parentNode.removeChild(this.element);
354    }
355    this.element = null;
356  }
357}
358
359/**
360 * Create a new spinner instance
361 */
362export function create(message, style = "right", animation = "wave") {
363  return new Spinner(message, style, animation);
364}
365
366/**
367 * Create a standalone whirlpool circle spinner (replaces CSS .spinner)
368 * Returns { element, start(), stop(), destroy() }
369 */
370export function createWhirlpool(size = 24) {
371  const sp = new Spinner('', 'clean', 'whirlpool');
372  sp._wpSize = size;
373  const el = sp.createElement();
374  // wrap in a div matching .spinner layout
375  const wrap = document.createElement('div');
376  wrap.className = 'spinner-whirlpool';
377  wrap.style.cssText = `width:${size}px;height:${size}px;margin:8px auto;`;
378  wrap.appendChild(el);
379  sp.start();
380  return { element: wrap, stop: () => sp.stop(), destroy: () => sp.destroy() };
381}
382
383/**
384 * A consistent inline loading row for list/library empty-states: a label plus
385 * the whirlpool spinner. Returns a detached element; the spinner self-stops
386 * once the element leaves the DOM (see _drawWhirlpool), so callers can just
387 * replace it with results — no manual cleanup needed.
388 */
389export function createLoadingRow(text = 'Loading…', size = 16) {
390  const sp = new Spinner('', 'clean', 'whirlpool');
391  sp._wpSize = size;
392  const canvas = sp.createElement();
393  const row = document.createElement('div');
394  row.className = 'lib-loading-row';
395  const label = document.createElement('span');
396  label.textContent = text;
397  row.appendChild(label);
398  row.appendChild(canvas);
399  sp.start();
400  return row;
401}
402
403export { Spinner };
404
405const spinnerModule = { create, createWhirlpool, createLoadingRow, Spinner };
406export default spinnerModule;

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.