11"use client"
22
3- import React , { useEffect , useMemo , useRef } from "react"
3+ import React , { useCallback , useEffect , useRef , useState } from "react"
44import { cn } from "@/lib/utils"
5- import { getFlickeringGridWorker } from "./flickering-singleton "
5+ import { subscribeRAF } from "./raf-scheduler "
66
77export interface FlickeringGridProps {
88 squareSize ?: number
@@ -14,147 +14,159 @@ export interface FlickeringGridProps {
1414 className ?: string
1515 maxOpacity ?: number
1616 shape ?: "circle" | "square" | "mixed"
17- fps ?: number
17+ }
18+
19+ interface GridState {
20+ ctx : CanvasRenderingContext2D
21+ cols : number
22+ rows : number
23+ squares : Float32Array
24+ shapes : Uint8Array
25+ step : number
1826}
1927
2028export const FlickeringGrid : React . FC < FlickeringGridProps > = ( {
2129 squareSize = 4 ,
2230 gridGap = 6 ,
2331 flickerChance = 0.3 ,
24- color = "rgb(0, 0, 0)" ,
32+ color = "rgb(0,0, 0)" ,
2533 width,
2634 height,
2735 className,
2836 maxOpacity = 0.3 ,
2937 shape = "square" ,
30- fps = 20 ,
3138} ) => {
3239 const canvasRef = useRef < HTMLCanvasElement > ( null )
3340 const containerRef = useRef < HTMLDivElement > ( null )
34- const idRef = useRef < string > ( typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto . randomUUID ( ) : Math . random ( ) . toString ( 36 ) . slice ( 2 ) )
35-
36- const rgbaPrefix = useMemo ( ( ) => {
37- const toRGBA = ( c : string ) => {
38- if ( typeof window === "undefined" ) return `rgba(0, 0, 0,`
39- const canvas = document . createElement ( "canvas" )
40- canvas . width = canvas . height = 1
41- const ctx = canvas . getContext ( "2d" , { alpha : true } )
42- if ( ! ctx ) return "rgba(255, 0, 0,"
43- ctx . fillStyle = c
44- ctx . fillRect ( 0 , 0 , 1 , 1 )
45- const [ r , g , b ] = Array . from ( ctx . getImageData ( 0 , 0 , 1 , 1 ) . data )
46- return `rgba(${ r } , ${ g } , ${ b } ,`
47- }
48- return toRGBA ( color )
49- } , [ color ] )
41+ const gridRef = useRef < GridState | null > ( null )
42+ const lastTimeRef = useRef ( 0 )
5043
44+ const [ isInView , setIsInView ] = useState ( false )
45+ const [ rgbaBase , setRgbaBase ] = useState ( "rgba(0,0,0," )
46+
47+ /* ---------- SSR-safe color parsing ---------- */
5148 useEffect ( ( ) => {
49+ const canvas = document . createElement ( "canvas" )
50+ canvas . width = canvas . height = 1
51+ const ctx = canvas . getContext ( "2d" )
52+ if ( ! ctx ) return
53+
54+ ctx . fillStyle = color
55+ ctx . fillRect ( 0 , 0 , 1 , 1 )
56+ const [ r , g , b ] = ctx . getImageData ( 0 , 0 , 1 , 1 ) . data
57+ setRgbaBase ( `rgba(${ r } ,${ g } ,${ b } ,` )
58+ } , [ color ] )
59+
60+ /* ---------- Canvas + grid setup ---------- */
61+ const setupGrid = useCallback ( ( ) => {
5262 const canvas = canvasRef . current
5363 const container = containerRef . current
5464 if ( ! canvas || ! container ) return
5565
56- const worker = getFlickeringGridWorker ( )
66+ const w = width ?? container . clientWidth
67+ const h = height ?? container . clientHeight
68+ const dpr = window . devicePixelRatio || 1
5769
58- // eslint-disable-next-line @typescript-eslint/no-explicit-any
59- const canOffscreen = typeof ( canvas as any ) . transferControlToOffscreen === "function"
70+ canvas . width = w * dpr
71+ canvas . height = h * dpr
72+ canvas . style . width = `${ w } px`
73+ canvas . style . height = `${ h } px`
6074
61- // Fallback: if OffscreenCanvas isn't available, do nothing here.
62- // (You can keep your original main-thread implementation as fallback if needed.)
63- if ( ! worker || ! canOffscreen ) {
64- // Optional: you can paste your original implementation as a fallback.
65- return
66- }
75+ const ctx = canvas . getContext ( "2d" )
76+ if ( ! ctx ) return
6777
68- const id = idRef . current
69- const dpr = window . devicePixelRatio || 1
78+ ctx . setTransform ( dpr , 0 , 0 , dpr , 0 , 0 )
7079
71- const getSize = ( ) => ( {
72- w : width ?? container . clientWidth ,
73- h : height ?? container . clientHeight ,
74- } )
80+ const step = squareSize + gridGap
81+ const cols = Math . floor ( w / step )
82+ const rows = Math . floor ( h / step )
7583
76- const { w, h } = getSize ( )
84+ const squares = new Float32Array ( cols * rows )
85+ const shapes = new Uint8Array ( cols * rows )
7786
78- // Style size (CSS pixels)
79- canvas . style . width = `${ w } px`
80- canvas . style . height = `${ h } px`
87+ for ( let i = 0 ; i < squares . length ; i ++ ) {
88+ squares [ i ] = Math . random ( ) * maxOpacity
89+ shapes [ i ] = shape === "mixed" ? ( Math . random ( ) < 0.5 ? 1 : 0 ) : shape === "circle" ? 1 : 0
90+ }
8191
82- // eslint-disable-next-line @typescript-eslint/no-explicit-any
83- const offscreen = ( canvas as any ) . transferControlToOffscreen ( ) as OffscreenCanvas
84-
85- worker . postMessage (
86- {
87- type : "init" ,
88- id,
89- canvas : offscreen ,
90- width : w ,
91- height : h ,
92- dpr,
93- squareSize,
94- gridGap,
95- flickerChance,
96- maxOpacity,
97- rgbaPrefix,
98- shape,
99- fps,
100- } ,
101- [ offscreen ]
102- )
92+ gridRef . current = { ctx, cols, rows, squares, shapes, step }
93+ } , [ squareSize , gridGap , maxOpacity , shape , width , height ] )
94+
95+ /* ---------- Animation ---------- */
96+ const animate = useCallback (
97+ ( time : number ) => {
98+ if ( ! isInView || ! gridRef . current ) return
99+
100+ const delta = ( time - lastTimeRef . current ) / 1000
101+ if ( delta < 0.05 ) return
102+ lastTimeRef . current = time
103+
104+ const { ctx, cols, rows, squares, shapes, step } = gridRef . current
105+
106+ const updates = Math . floor ( squares . length * flickerChance * delta )
107+ for ( let i = 0 ; i < updates ; i ++ ) {
108+ const idx = ( Math . random ( ) * squares . length ) | 0
109+ squares [ idx ] = Math . random ( ) * maxOpacity
110+ }
111+
112+ ctx . clearRect ( 0 , 0 , cols * step , rows * step )
113+
114+ for ( let i = 0 ; i < cols ; i ++ ) {
115+ for ( let j = 0 ; j < rows ; j ++ ) {
116+ const idx = i * rows + j
117+ const o = squares [ idx ]
118+ if ( o < 0.01 ) continue
119+
120+ ctx . fillStyle = `${ rgbaBase } ${ o } )`
121+ const x = i * step
122+ const y = j * step
123+
124+ if ( shapes [ idx ] ) {
125+ ctx . beginPath ( )
126+ ctx . arc ( x + squareSize / 2 , y + squareSize / 2 , squareSize / 2 , 0 , Math . PI * 2 )
127+ ctx . fill ( )
128+ } else {
129+ ctx . fillRect ( x , y , squareSize , squareSize )
130+ }
131+ }
132+ }
133+ } ,
134+ [ isInView , flickerChance , maxOpacity , rgbaBase , squareSize ]
135+ )
136+
137+ useEffect ( ( ) => {
138+ setupGrid ( )
103139
104140 const resizeObserver = new ResizeObserver ( ( ) => {
105- const { w : newW , h : newH } = getSize ( )
106- canvas . style . width = `${ newW } px`
107- canvas . style . height = `${ newH } px`
108-
109- worker . postMessage ( {
110- type : "resize" ,
111- id,
112- width : newW ,
113- height : newH ,
114- dpr : window . devicePixelRatio || 1 ,
115- } )
141+ setupGrid ( )
116142 } )
117- resizeObserver . observe ( container )
118-
119- const intersectionObserver = new IntersectionObserver (
120- ( [ entry ] ) => {
121- worker . postMessage ( {
122- type : "visibility" ,
123- id,
124- inView : entry . isIntersecting ,
125- } )
126- } ,
127- { threshold : 0 }
128- )
129- intersectionObserver . observe ( canvas )
143+
144+ if ( containerRef . current ) {
145+ resizeObserver . observe ( containerRef . current )
146+ }
147+
148+ const intersectionObserver = new IntersectionObserver ( ( [ entry ] ) => {
149+ setIsInView ( entry . isIntersecting )
150+ } )
151+
152+ if ( canvasRef . current ) {
153+ intersectionObserver . observe ( canvasRef . current )
154+ }
130155
131156 return ( ) => {
132157 resizeObserver . disconnect ( )
133158 intersectionObserver . disconnect ( )
134- worker . postMessage ( { type : "destroy" , id } )
135159 }
136- } , [ width , height ] ) // size is handled by observer; this just re-inits if fixed props change
160+ } , [ setupGrid ] )
137161
138- // Prop updates (color, flicker settings, etc.)
139162 useEffect ( ( ) => {
140- const worker = getFlickeringGridWorker ( )
141- if ( ! worker ) return
142- worker . postMessage ( {
143- type : "update" ,
144- id : idRef . current ,
145- squareSize,
146- gridGap,
147- flickerChance,
148- maxOpacity,
149- rgbaPrefix,
150- shape,
151- fps,
152- } )
153- } , [ squareSize , gridGap , flickerChance , maxOpacity , rgbaPrefix , shape , fps ] )
163+ if ( ! isInView ) return
164+ return subscribeRAF ( animate )
165+ } , [ isInView , animate ] )
154166
155167 return (
156- < div ref = { containerRef } className = { cn ( "h-full w-full bg-transparent " , className ) } >
157- < canvas ref = { canvasRef } className = "pointer-events-none" style = { { backgroundColor : "transparent" } } />
168+ < div ref = { containerRef } className = { cn ( "h-full w-full" , className ) } >
169+ < canvas ref = { canvasRef } className = "pointer-events-none" />
158170 </ div >
159171 )
160172}
0 commit comments