PageSourceSearch

https://static.0din.ai/assets/controllers/count_up_controller-59198d42.js

js 0din.ai collected 2026-10-04 00:12:26 UTC 3,306 bytes, 88 lines download raw bytes

1import { Controller } from '@hotwired/stimulus'
2
3const DURATION_MS = 900
4
5/*
6 * Fetches a number this element is standing in for, then counts up to it when the element
7 * scrolls into view.
8 *
9 * For a figure that comes from somewhere slow enough that waiting on it would hold up the
10 * paint — /susfactor's GitHub star stamp, which is someone else's API. The server renders a
11 * fallback, this replaces it, and the swap is animated because the number arriving is worth
12 * looking at.
13 *
14 * `url` must answer JSON `{ count: <number> }`.
15 *
16 * A failed fetch is deliberately swallowed: the fallback is already on screen and is what
17 * this is meant to degrade to, so there is nothing to report and nothing to change.
18 */
19export default class extends Controller {
20  static values = { url: String }
21
22  connect () {
23    this.abortController = new AbortController()
24
25    fetch(this.urlValue, { headers: { Accept: 'application/json' }, signal: this.abortController.signal })
26      .then(response => response.ok ? response.json() : Promise.reject(response.status))
27      .then(({ count }) => this.arrive(count))
28      .catch(() => {})
29  }
30
31  disconnect () {
32    // Aborting rejects the fetch, which the catch above swallows — so a response landing
33    // after the element is gone can no longer observe a detached node.
34    this.abortController.abort()
35    cancelAnimationFrame(this.frame)
36    this.observer?.disconnect()
37    this.observer = null
38  }
39
40  // Under reduced motion the number just changes — there is no animation to wait for, so
41  // there is no reason to hold it back until the row is on screen either.
42  //
43  // Read here rather than cached at connect, and no listener needed for it: a fetch and a
44  // scroll both stand between the two, so this is already the later moment. Sampling at
45  // connect meant a preference turned on in between still got the 900ms count.
46  arrive (target) {
47    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
48      this.write(target)
49      return
50    }
51
52    // One-shot: an element already on screen fires this immediately, which is the right
53    // behaviour for a fetch that resolved while the visitor was looking at the stamp.
54    this.observer = new IntersectionObserver(([entry]) => {
55      if (!entry.isIntersecting) return
56      this.disconnectObserver()
57      this.countUp(target)
58    })
59    this.observer.observe(this.element)
60  }
61
62  disconnectObserver () {
63    this.observer?.disconnect()
64    this.observer = null
65  }
66
67  countUp (target) {
68    // Whatever the server rendered, minus the delimiter it rendered it with.
69    const from = Number(this.element.textContent.replace(/[^0-9.-]/g, '')) || 0
70    const start = performance.now()
71
72    const step = now => {
73      // easeOutCubic, the same curve the demo console's gauge settles on.
74      const t = Math.min((now - start) / DURATION_MS, 1)
75      const eased = 1 - Math.pow(1 - t, 3)
76      this.write(Math.round(from + (target - from) * eased))
77      if (t < 1) this.frame = requestAnimationFrame(step)
78    }
79
80    this.frame = requestAnimationFrame(step)
81  }
82
83  // Grouped the way number_with_delimiter rendered the fallback, so the swap does not also
84  // change how the number is punctuated.
85  write (value) {
86    this.element.textContent = value.toLocaleString('en-US')
87  }
88}

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.