@nous-research/ui 0.14.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/README.md +24 -4
  3. package/dist/fonts.js +1 -0
  4. package/dist/hooks/use-capped-frame.js +1 -0
  5. package/dist/hooks/use-css-var-dims.js +1 -0
  6. package/dist/hooks/use-gpu-tier.js +1 -0
  7. package/dist/hooks/use-render-loop.js +1 -0
  8. package/dist/hooks/use-smooth-controls.js +1 -0
  9. package/dist/index.js +1 -0
  10. package/dist/ui/basic-page.js +1 -0
  11. package/dist/ui/components/animated-count.js +1 -0
  12. package/dist/ui/components/ascii.js +1 -0
  13. package/dist/ui/components/badge.js +2 -1
  14. package/dist/ui/components/badges/nous-girl.js +1 -0
  15. package/dist/ui/components/blend-mode.js +1 -0
  16. package/dist/ui/components/blink.js +1 -0
  17. package/dist/ui/components/button.js +2 -1
  18. package/dist/ui/components/checkbox.js +1 -0
  19. package/dist/ui/components/command-block.js +4 -3
  20. package/dist/ui/components/cursor.js +1 -0
  21. package/dist/ui/components/dropdown-menu.js +1 -0
  22. package/dist/ui/components/fit-text/index.js +1 -0
  23. package/dist/ui/components/graphs/bar-chart.js +1 -0
  24. package/dist/ui/components/graphs/index.js +1 -0
  25. package/dist/ui/components/graphs/line-chart.js +1 -0
  26. package/dist/ui/components/graphs/utils.js +1 -0
  27. package/dist/ui/components/grid/index.js +1 -0
  28. package/dist/ui/components/hover-bg.js +1 -0
  29. package/dist/ui/components/icons/arrow.js +1 -0
  30. package/dist/ui/components/icons/check.js +1 -0
  31. package/dist/ui/components/icons/chevron.js +1 -0
  32. package/dist/ui/components/icons/discord.js +1 -0
  33. package/dist/ui/components/icons/eye.js +1 -0
  34. package/dist/ui/components/icons/gear.js +1 -0
  35. package/dist/ui/components/icons/github.js +1 -0
  36. package/dist/ui/components/icons/hamburger.js +1 -0
  37. package/dist/ui/components/icons/heart.js +1 -0
  38. package/dist/ui/components/icons/index.js +1 -0
  39. package/dist/ui/components/icons/link.js +1 -0
  40. package/dist/ui/components/icons/minus.js +1 -0
  41. package/dist/ui/components/icons/search.js +1 -0
  42. package/dist/ui/components/image-distortion.js +1 -0
  43. package/dist/ui/components/leva-client.js +1 -0
  44. package/dist/ui/components/list-item.js +3 -2
  45. package/dist/ui/components/modal/index.js +1 -0
  46. package/dist/ui/components/modal/modal.css +1 -1
  47. package/dist/ui/components/overlays/blend-modes.js +1 -0
  48. package/dist/ui/components/overlays/glitch.js +1 -0
  49. package/dist/ui/components/overlays/greys.js +1 -0
  50. package/dist/ui/components/overlays/index.js +1 -0
  51. package/dist/ui/components/overlays/lens-layers.js +1 -0
  52. package/dist/ui/components/overlays/lens.js +1 -0
  53. package/dist/ui/components/overlays/noise.js +1 -0
  54. package/dist/ui/components/overlays/vignette.js +1 -0
  55. package/dist/ui/components/poster.js +1 -0
  56. package/dist/ui/components/progress.js +1 -0
  57. package/dist/ui/components/scene-canvas.js +1 -0
  58. package/dist/ui/components/scramble.js +1 -0
  59. package/dist/ui/components/segmented.js +5 -4
  60. package/dist/ui/components/select.js +1 -0
  61. package/dist/ui/components/selection-switcher.js +1 -0
  62. package/dist/ui/components/shader.js +1 -0
  63. package/dist/ui/components/socials.js +1 -0
  64. package/dist/ui/components/spinner.js +1 -0
  65. package/dist/ui/components/stats.js +2 -1
  66. package/dist/ui/components/switch.js +1 -0
  67. package/dist/ui/components/tabs.js +4 -3
  68. package/dist/ui/components/terminal-demo.js +2 -1
  69. package/dist/ui/components/theme-toggle.js +1 -0
  70. package/dist/ui/components/tier-card.js +2 -1
  71. package/dist/ui/components/tv.js +1 -0
  72. package/dist/ui/components/typography/h1.js +1 -0
  73. package/dist/ui/components/typography/h2.js +1 -0
  74. package/dist/ui/components/typography/index.js +1 -0
  75. package/dist/ui/components/typography/legend.js +1 -0
  76. package/dist/ui/components/typography/small.js +1 -0
  77. package/dist/ui/components/watchlist.js +2 -1
  78. package/dist/ui/footer.js +1 -0
  79. package/dist/ui/globals.css +33 -1
  80. package/dist/ui/header.js +1 -0
  81. package/dist/ui/layout-wrapper.js +2 -1
  82. package/dist/utils/color.js +1 -0
  83. package/dist/utils/index.js +1 -0
  84. package/dist/utils/poly.js +1 -0
  85. package/package.json +4 -2
  86. package/src/assets/filler-bg0.webp +0 -0
  87. package/src/assets.d.ts +38 -0
  88. package/src/fonts/Collapse-Bold.woff2 +0 -0
  89. package/src/fonts/Collapse-BoldItalic.woff2 +0 -0
  90. package/src/fonts/Collapse-Italic.woff2 +0 -0
  91. package/src/fonts/Collapse-Light.woff2 +0 -0
  92. package/src/fonts/Collapse-LightItalic.woff2 +0 -0
  93. package/src/fonts/Collapse-Regular.woff2 +0 -0
  94. package/src/fonts/Collapse-Thin.woff2 +0 -0
  95. package/src/fonts/Collapse-ThinItalic.woff2 +0 -0
  96. package/src/fonts/Mondwest-Regular.woff2 +0 -0
  97. package/src/fonts/Neuebit-Bold.woff2 +0 -0
  98. package/src/fonts/RulesCompressed-Medium.woff2 +0 -0
  99. package/src/fonts/RulesCompressed-Regular.woff2 +0 -0
  100. package/src/fonts/RulesExpanded-Bold.woff2 +0 -0
  101. package/src/fonts/RulesExpanded-Regular.woff2 +0 -0
  102. package/src/fonts.ts +6 -0
  103. package/src/hooks/use-capped-frame.ts +18 -0
  104. package/src/hooks/use-css-var-dims.ts +39 -0
  105. package/src/hooks/use-gpu-tier.ts +165 -0
  106. package/src/hooks/use-render-loop.ts +121 -0
  107. package/src/hooks/use-smooth-controls.ts +318 -0
  108. package/src/index.ts +109 -0
  109. package/src/ui/basic-page.tsx +34 -0
  110. package/src/ui/build.css +4 -0
  111. package/src/ui/components/animated-count.stories.tsx +67 -0
  112. package/src/ui/components/animated-count.tsx +168 -0
  113. package/src/ui/components/ascii.stories.tsx +30 -0
  114. package/src/ui/components/ascii.tsx +110 -0
  115. package/src/ui/components/badge.stories.tsx +31 -0
  116. package/src/ui/components/badge.tsx +60 -0
  117. package/src/ui/components/badges/nous-girl.tsx +52 -0
  118. package/src/ui/components/blend-mode.stories.tsx +33 -0
  119. package/src/ui/components/blend-mode.tsx +129 -0
  120. package/src/ui/components/blink.stories.tsx +32 -0
  121. package/src/ui/components/blink.tsx +21 -0
  122. package/src/ui/components/button.stories.tsx +68 -0
  123. package/src/ui/components/button.tsx +170 -0
  124. package/src/ui/components/checkbox.stories.tsx +113 -0
  125. package/src/ui/components/checkbox.tsx +36 -0
  126. package/src/ui/components/command-block.stories.tsx +52 -0
  127. package/src/ui/components/command-block.tsx +86 -0
  128. package/src/ui/components/cursor.tsx +115 -0
  129. package/src/ui/components/dropdown-menu.stories.tsx +52 -0
  130. package/src/ui/components/dropdown-menu.tsx +117 -0
  131. package/src/ui/components/fit-text/fit-text.css +42 -0
  132. package/src/ui/components/fit-text/index.stories.tsx +33 -0
  133. package/src/ui/components/fit-text/index.tsx +45 -0
  134. package/src/ui/components/graphs/bar-chart.tsx +153 -0
  135. package/src/ui/components/graphs/index.stories.tsx +64 -0
  136. package/src/ui/components/graphs/index.tsx +4 -0
  137. package/src/ui/components/graphs/line-chart.tsx +213 -0
  138. package/src/ui/components/graphs/utils.tsx +265 -0
  139. package/src/ui/components/grid/grid.css +79 -0
  140. package/src/ui/components/grid/index.tsx +19 -0
  141. package/src/ui/components/hover-bg.stories.tsx +29 -0
  142. package/src/ui/components/hover-bg.tsx +15 -0
  143. package/src/ui/components/icons/arrow.tsx +42 -0
  144. package/src/ui/components/icons/check.tsx +14 -0
  145. package/src/ui/components/icons/chevron.tsx +45 -0
  146. package/src/ui/components/icons/discord.tsx +16 -0
  147. package/src/ui/components/icons/eye.tsx +12 -0
  148. package/src/ui/components/icons/gear.tsx +51 -0
  149. package/src/ui/components/icons/github.tsx +16 -0
  150. package/src/ui/components/icons/hamburger.tsx +52 -0
  151. package/src/ui/components/icons/heart.tsx +12 -0
  152. package/src/ui/components/icons/index.ts +12 -0
  153. package/src/ui/components/icons/link.tsx +14 -0
  154. package/src/ui/components/icons/minus.tsx +14 -0
  155. package/src/ui/components/icons/search.tsx +28 -0
  156. package/src/ui/components/image-distortion.stories.tsx +120 -0
  157. package/src/ui/components/image-distortion.tsx +498 -0
  158. package/src/ui/components/leva-client.tsx +14 -0
  159. package/src/ui/components/list-item.stories.tsx +83 -0
  160. package/src/ui/components/list-item.tsx +37 -0
  161. package/src/ui/components/modal/index.stories.tsx +46 -0
  162. package/src/ui/components/modal/index.tsx +48 -0
  163. package/src/ui/components/modal/modal.css +36 -0
  164. package/src/ui/components/overlays/blend-modes.ts +13 -0
  165. package/src/ui/components/overlays/glitch.tsx +243 -0
  166. package/src/ui/components/overlays/greys.tsx +386 -0
  167. package/src/ui/components/overlays/index.tsx +47 -0
  168. package/src/ui/components/overlays/lens-layers.tsx +119 -0
  169. package/src/ui/components/overlays/lens.ts +91 -0
  170. package/src/ui/components/overlays/noise.tsx +174 -0
  171. package/src/ui/components/overlays/vignette.tsx +60 -0
  172. package/src/ui/components/poster.stories.tsx +513 -0
  173. package/src/ui/components/poster.tsx +411 -0
  174. package/src/ui/components/progress.stories.tsx +48 -0
  175. package/src/ui/components/progress.tsx +56 -0
  176. package/src/ui/components/scene-canvas.tsx +254 -0
  177. package/src/ui/components/scramble.stories.tsx +49 -0
  178. package/src/ui/components/scramble.tsx +95 -0
  179. package/src/ui/components/segmented.stories.tsx +101 -0
  180. package/src/ui/components/segmented.tsx +81 -0
  181. package/src/ui/components/select.stories.tsx +88 -0
  182. package/src/ui/components/select.tsx +267 -0
  183. package/src/ui/components/selection-switcher.tsx +44 -0
  184. package/src/ui/components/shader.tsx +83 -0
  185. package/src/ui/components/socials.tsx +42 -0
  186. package/src/ui/components/spinner.stories.tsx +101 -0
  187. package/src/ui/components/spinner.tsx +60 -0
  188. package/src/ui/components/stats.stories.tsx +24 -0
  189. package/src/ui/components/stats.tsx +53 -0
  190. package/src/ui/components/switch.stories.tsx +77 -0
  191. package/src/ui/components/switch.tsx +48 -0
  192. package/src/ui/components/tabs.stories.tsx +101 -0
  193. package/src/ui/components/tabs.tsx +66 -0
  194. package/src/ui/components/terminal-demo.stories.tsx +67 -0
  195. package/src/ui/components/terminal-demo.tsx +189 -0
  196. package/src/ui/components/theme-toggle.stories.tsx +47 -0
  197. package/src/ui/components/theme-toggle.tsx +66 -0
  198. package/src/ui/components/tier-card.stories.tsx +217 -0
  199. package/src/ui/components/tier-card.tsx +190 -0
  200. package/src/ui/components/tv.stories.tsx +37 -0
  201. package/src/ui/components/tv.tsx +257 -0
  202. package/src/ui/components/typography/h1.tsx +18 -0
  203. package/src/ui/components/typography/h2.tsx +18 -0
  204. package/src/ui/components/typography/index.tsx +54 -0
  205. package/src/ui/components/typography/legend.tsx +24 -0
  206. package/src/ui/components/typography/small.tsx +11 -0
  207. package/src/ui/components/watchlist.stories.tsx +33 -0
  208. package/src/ui/components/watchlist.tsx +105 -0
  209. package/src/ui/fonts.css +63 -0
  210. package/src/ui/footer.tsx +111 -0
  211. package/src/ui/globals.css +383 -0
  212. package/src/ui/header.tsx +398 -0
  213. package/src/ui/layout-wrapper.tsx +11 -0
  214. package/src/utils/color.ts +21 -0
  215. package/src/utils/index.ts +62 -0
  216. package/src/utils/poly.ts +26 -0
@@ -0,0 +1,38 @@
1
+ interface StaticImageData {
2
+ src: string
3
+ height: number
4
+ width: number
5
+ blurDataURL?: string
6
+ blurWidth?: number
7
+ blurHeight?: number
8
+ }
9
+
10
+ declare module '*.jpg' {
11
+ const content: StaticImageData
12
+ export default content
13
+ }
14
+
15
+ declare module '*.jpeg' {
16
+ const content: StaticImageData
17
+ export default content
18
+ }
19
+
20
+ declare module '*.png' {
21
+ const content: StaticImageData
22
+ export default content
23
+ }
24
+
25
+ declare module '*.svg' {
26
+ const content: StaticImageData
27
+ export default content
28
+ }
29
+
30
+ declare module '*.gif' {
31
+ const content: StaticImageData
32
+ export default content
33
+ }
34
+
35
+ declare module '*.webp' {
36
+ const content: StaticImageData
37
+ export default content
38
+ }
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/src/fonts.ts ADDED
@@ -0,0 +1,6 @@
1
+ /** CSS variable names for the design language fonts. Set automatically by fonts.css. */
2
+ export const FONT_SANS = '--font-sans'
3
+ export const FONT_MONO = '--font-mono'
4
+ export const FONT_RULES_COMPRESSED = '--font-rules-compressed'
5
+ export const FONT_RULES_EXPANDED = '--font-rules-expanded'
6
+ export const FONT_MONDWEST = '--font-mondwest'
@@ -0,0 +1,18 @@
1
+ 'use client'
2
+
3
+ import { useFrame, useThree } from '@react-three/fiber'
4
+ import type { RenderCallback } from '@react-three/fiber'
5
+ import { useRef } from 'react'
6
+
7
+ export function useCappedFrame(cb: RenderCallback, max?: number) {
8
+ const last = useRef(performance.now())
9
+ const { size } = useThree()
10
+ const interval = 1e3 / (max ?? (size.width < 1024 ? 60 : 120))
11
+
12
+ useFrame((st, delta) => {
13
+ if (performance.now() - last.current > interval) {
14
+ last.current = performance.now()
15
+ cb(st, delta)
16
+ }
17
+ })
18
+ }
@@ -0,0 +1,39 @@
1
+ 'use client'
2
+
3
+ import { useEffect } from 'react'
4
+
5
+ export function useCssVarDims(
6
+ name: string,
7
+ ref: React.RefObject<HTMLElement | null>
8
+ ) {
9
+ useEffect(() => {
10
+ if (!ref.current) {
11
+ return
12
+ }
13
+
14
+ const update = (width: number, height: number) => {
15
+ document.documentElement.style.setProperty(
16
+ `--${name}-width`,
17
+ `${width}px`
18
+ )
19
+
20
+ document.documentElement.style.setProperty(
21
+ `--${name}-height`,
22
+ `${height}px`
23
+ )
24
+ }
25
+
26
+ const { height, width } = ref.current.getBoundingClientRect()
27
+ update(width, height)
28
+
29
+ const ro = new ResizeObserver(entries => {
30
+ for (const entry of entries) {
31
+ update(entry.contentRect.width, entry.contentRect.height)
32
+ }
33
+ })
34
+
35
+ ro.observe(ref.current)
36
+
37
+ return () => ro.disconnect()
38
+ }, [name, ref])
39
+ }
@@ -0,0 +1,165 @@
1
+ 'use client'
2
+
3
+ import { useStore } from '@nanostores/react'
4
+ import { atom } from 'nanostores'
5
+
6
+ /**
7
+ * Tiers:
8
+ * 0 — no WebGL / software renderer / prefers-reduced-motion / WebGL ctx creation failed
9
+ * 1 — low-end GPU (integrated, mobile, or failed perf benchmark)
10
+ * 2 — capable GPU (discrete / high-end integrated)
11
+ *
12
+ * Detection runs **synchronously** the first time this module is evaluated
13
+ * on the client (see the IIFE at the bottom of the file). That means any
14
+ * consumer reading `$gpuTier` during its first render already sees the
15
+ * post-detection value, so WebGL components can avoid trying to create a
16
+ * `THREE.WebGLRenderer` on hardware where context creation will fail.
17
+ *
18
+ * The previous version ran the probe inside `nanostores`'s `onMount`
19
+ * lifecycle, which fires from a microtask after the first listener
20
+ * subscribes — i.e. after the first React commit. By that point overlay
21
+ * components had already executed `new THREE.WebGLRenderer(...)` against
22
+ * the optimistic default, logged `Error creating WebGL context`, and only
23
+ * unmounted on a follow-up render. Eager module-load detection closes that
24
+ * race.
25
+ */
26
+ export const $gpuTier = atom<GpuTier>(2)
27
+
28
+ const SOFTWARE_PATTERNS =
29
+ /swiftshader|llvmpipe|softpipe|software|microsoft basic/i
30
+
31
+ const LOW_END_PATTERNS =
32
+ /intel.*hd|intel.*uhd|intel.*iris|mali|adreno\s?[1-5]|powervr|apple gpu/i
33
+
34
+ let detected = false
35
+
36
+ function detectGpuTier() {
37
+ if (detected || typeof window === 'undefined') {
38
+ return
39
+ }
40
+
41
+ detected = true
42
+
43
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
44
+ $gpuTier.set(0)
45
+
46
+ return
47
+ }
48
+
49
+ let gl: null | WebGLRenderingContext = null
50
+
51
+ try {
52
+ const canvas = document.createElement('canvas')
53
+ gl = (canvas.getContext('webgl') ||
54
+ canvas.getContext('experimental-webgl')) as null | WebGLRenderingContext
55
+ } catch {
56
+ // Some sandboxed / hardened contexts throw on getContext rather than
57
+ // returning null (e.g. certain corporate browser policies). Treat as
58
+ // "no WebGL available".
59
+ $gpuTier.set(0)
60
+
61
+ return
62
+ }
63
+
64
+ if (!gl) {
65
+ $gpuTier.set(0)
66
+
67
+ return
68
+ }
69
+
70
+ const ext = gl.getExtension('WEBGL_debug_renderer_info')
71
+ const renderer = String(
72
+ ext
73
+ ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL)
74
+ : gl.getParameter(gl.RENDERER)
75
+ )
76
+
77
+ if (SOFTWARE_PATTERNS.test(renderer)) {
78
+ $gpuTier.set(0)
79
+ gl.getExtension('WEBGL_lose_context')?.loseContext()
80
+
81
+ return
82
+ }
83
+
84
+ if (LOW_END_PATTERNS.test(renderer)) {
85
+ $gpuTier.set(1)
86
+ gl.getExtension('WEBGL_lose_context')?.loseContext()
87
+
88
+ return
89
+ }
90
+
91
+ $gpuTier.set(2)
92
+
93
+ runBenchmark(gl)
94
+ .then(fps => $gpuTier.set(fps < 30 ? 1 : 2))
95
+ .catch(() => $gpuTier.set(1))
96
+ .finally(() => gl?.getExtension('WEBGL_lose_context')?.loseContext())
97
+ }
98
+
99
+ if (typeof window !== 'undefined') {
100
+ detectGpuTier()
101
+ }
102
+
103
+ function runBenchmark(gl: WebGLRenderingContext): Promise<number> {
104
+ return new Promise(resolve => {
105
+ const vs = gl.createShader(gl.VERTEX_SHADER)!
106
+ const fs = gl.createShader(gl.FRAGMENT_SHADER)!
107
+ gl.shaderSource(
108
+ vs,
109
+ 'attribute vec2 a;void main(){gl_Position=vec4(a,0,1);}'
110
+ )
111
+ gl.shaderSource(
112
+ fs,
113
+ 'precision highp float;uniform float t;void main(){float v=0.;for(int i=0;i<64;i++)v+=sin(float(i)*t*.01);gl_FragColor=vec4(v*.001);}'
114
+ )
115
+ gl.compileShader(vs)
116
+ gl.compileShader(fs)
117
+
118
+ const prog = gl.createProgram()!
119
+ gl.attachShader(prog, vs)
120
+ gl.attachShader(prog, fs)
121
+ gl.linkProgram(prog)
122
+ gl.useProgram(prog)
123
+
124
+ const buf = gl.createBuffer()
125
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf)
126
+ gl.bufferData(
127
+ gl.ARRAY_BUFFER,
128
+ new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
129
+ gl.STATIC_DRAW
130
+ )
131
+ const a = gl.getAttribLocation(prog, 'a')
132
+ gl.enableVertexAttribArray(a)
133
+ gl.vertexAttribPointer(a, 2, gl.FLOAT, false, 0, 0)
134
+
135
+ const uT = gl.getUniformLocation(prog, 't')
136
+ let frames = 0
137
+ const start = performance.now()
138
+
139
+ const tick = () => {
140
+ gl.uniform1f(uT, frames)
141
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
142
+ gl.finish()
143
+ frames++
144
+
145
+ if (performance.now() - start < 200) {
146
+ requestAnimationFrame(tick)
147
+ } else {
148
+ const elapsed = performance.now() - start
149
+ gl.deleteProgram(prog)
150
+ gl.deleteShader(vs)
151
+ gl.deleteShader(fs)
152
+ gl.deleteBuffer(buf)
153
+ resolve((frames / elapsed) * 1000)
154
+ }
155
+ }
156
+
157
+ requestAnimationFrame(tick)
158
+ })
159
+ }
160
+
161
+ export function useGpuTier() {
162
+ return useStore($gpuTier)
163
+ }
164
+
165
+ type GpuTier = 0 | 1 | 2
@@ -0,0 +1,121 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Visibility- and intersection-aware render-loop helper for the WebGL
5
+ * overlays.
6
+ *
7
+ * The overlays were previously running fragment shaders at 60fps for the
8
+ * entire lifetime of the page — including when the tab was hidden, the
9
+ * canvas had been scrolled out of view, or the user had been idle for
10
+ * hours. On retina laptops the compositor cost of mix-blend-mode on a
11
+ * full-viewport canvas plus continuous WebGL rasterisation is enough to
12
+ * keep the GPU hot indefinitely, which is what manifests as "fans go
13
+ * crazy after 2 hours of idle".
14
+ *
15
+ * `runRenderLoop` wraps a frame callback so that it:
16
+ *
17
+ * 1. Pauses entirely when `document.hidden` is true (background tab,
18
+ * minimised window, screen locked).
19
+ * 2. Pauses when the canvas's bounding rect is offscreen (we tell
20
+ * `IntersectionObserver` to look at the canvas itself).
21
+ * 3. Optionally caps the frame rate via a min-interval — the previous
22
+ * `gpuTier === 1 ? setTimeout(loop, 100) : raf` trick is preserved
23
+ * and extended so even tier-2 GPUs cap at e.g. 30fps for overlays
24
+ * that don't need 60.
25
+ *
26
+ * The callback receives the *delta* time in seconds since the last call
27
+ * (so `uTime` advances correctly across pauses without ever skipping
28
+ * forward by hours).
29
+ */
30
+
31
+ interface RunRenderLoopOptions {
32
+ /** Element to observe with IntersectionObserver. When fully out of
33
+ * view, the loop pauses. Pass the canvas element itself. */
34
+ el: Element
35
+ /** Min ms between frames. 0 = no cap (uses requestAnimationFrame).
36
+ * Anything > 0 uses setTimeout-driven scheduling. */
37
+ minIntervalMs?: number
38
+ /** Frame callback. Receives the elapsed seconds since the previous
39
+ * *executed* frame (not since the previous scheduled frame), so
40
+ * uniforms keyed off this value will not jump after a long pause. */
41
+ onFrame: (deltaSeconds: number) => void
42
+ }
43
+
44
+ export function runRenderLoop({
45
+ el,
46
+ minIntervalMs = 0,
47
+ onFrame
48
+ }: RunRenderLoopOptions) {
49
+ let running = true
50
+ let visible = !document.hidden
51
+ let inView = true
52
+ let last = performance.now()
53
+ let raf = 0
54
+ let timer: ReturnType<typeof setTimeout> | undefined
55
+
56
+ const onVisibility = () => {
57
+ visible = !document.hidden
58
+
59
+ // When we come back from a hidden tab, reset the clock so the next
60
+ // frame's delta is ~one frame, not "hours since I was hidden".
61
+ if (visible) {
62
+ last = performance.now()
63
+ schedule()
64
+ }
65
+ }
66
+
67
+ const io = new IntersectionObserver(
68
+ entries => {
69
+ const wasInView = inView
70
+ inView = entries.some(e => e.isIntersecting)
71
+
72
+ if (!wasInView && inView) {
73
+ last = performance.now()
74
+ schedule()
75
+ }
76
+ },
77
+ { threshold: 0 }
78
+ )
79
+
80
+ io.observe(el)
81
+ document.addEventListener('visibilitychange', onVisibility)
82
+
83
+ const tick = () => {
84
+ if (!running) return
85
+
86
+ if (!visible || !inView) {
87
+ // Don't reschedule — we'll be re-kicked by visibilitychange or IO.
88
+ return
89
+ }
90
+
91
+ const now = performance.now()
92
+ const delta = (now - last) / 1000
93
+ last = now
94
+
95
+ onFrame(delta)
96
+ schedule()
97
+ }
98
+
99
+ function schedule() {
100
+ if (!running || !visible || !inView) return
101
+
102
+ if (minIntervalMs > 0) {
103
+ timer = setTimeout(tick, minIntervalMs)
104
+ } else {
105
+ raf = requestAnimationFrame(tick)
106
+ }
107
+ }
108
+
109
+ schedule()
110
+
111
+ return () => {
112
+ running = false
113
+ io.disconnect()
114
+ document.removeEventListener('visibilitychange', onVisibility)
115
+ cancelAnimationFrame(raf)
116
+
117
+ if (timer !== undefined) {
118
+ clearTimeout(timer)
119
+ }
120
+ }
121
+ }