@hdcodedev/snowfall 1.0.10 → 1.0.11

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.
package/dist/index.mjs CHANGED
@@ -1,9 +1,9 @@
1
1
  "use client";
2
2
 
3
- // src/Snowfall.tsx
4
- import { useEffect, useRef, useState as useState2 } from "react";
3
+ // src/components/Snowfall.tsx
4
+ import { useEffect, useRef as useRef4, useState as useState2 } from "react";
5
5
 
6
- // src/SnowfallProvider.tsx
6
+ // src/components/SnowfallProvider.tsx
7
7
  import { createContext, useContext, useState } from "react";
8
8
  import { jsx } from "react/jsx-runtime";
9
9
  var DEFAULT_PHYSICS = {
@@ -24,7 +24,9 @@ var DEFAULT_PHYSICS = {
24
24
  MIN: 0.5,
25
25
  MAX: 1.6
26
26
  },
27
- MAX_SURFACES: 15
27
+ MAX_SURFACES: 15,
28
+ COLLISION_CHECK_RATE: 0.3
29
+ // 30% of snowflakes check collisions per frame
28
30
  };
29
31
  var SnowfallContext = createContext(void 0);
30
32
  function SnowfallProvider({ children, initialDebug = false }) {
@@ -79,7 +81,7 @@ function useSnowfall() {
79
81
  return context;
80
82
  }
81
83
 
82
- // src/utils/snowfall/constants.ts
84
+ // src/core/constants.ts
83
85
  var ATTR_SNOWFALL = "data-snowfall";
84
86
  var VAL_IGNORE = "ignore";
85
87
  var VAL_TOP = "top";
@@ -90,7 +92,7 @@ var ROLE_BANNER = "banner";
90
92
  var ROLE_CONTENTINFO = "contentinfo";
91
93
  var TAU = Math.PI * 2;
92
94
 
93
- // src/utils/snowfall/dom.ts
95
+ // src/core/dom.ts
94
96
  var BOTTOM_TAGS = [TAG_HEADER];
95
97
  var BOTTOM_ROLES = [ROLE_BANNER];
96
98
  var AUTO_DETECT_TAGS = [TAG_HEADER, TAG_FOOTER, "article", "section", "aside", "nav"];
@@ -172,7 +174,7 @@ var getElementRects = (accumulationMap) => {
172
174
  return elementRects;
173
175
  };
174
176
 
175
- // src/utils/snowfall/physics.ts
177
+ // src/core/physics.ts
176
178
  var OPACITY_BUCKETS = [0.3, 0.5, 0.7, 0.9];
177
179
  var quantizeOpacity = (opacity) => {
178
180
  return OPACITY_BUCKETS.reduce(
@@ -219,6 +221,7 @@ var createSnowflake = (worldWidth, config, isBackground = false) => {
219
221
  const opacity = quantizeOpacity(rawOpacity);
220
222
  const rawGlowOpacity = opacity * 0.2;
221
223
  const glowOpacity = quantizeOpacity(rawGlowOpacity);
224
+ const initialWobble = noise.wobblePhase * TAU;
222
225
  return {
223
226
  x,
224
227
  y: window.scrollY - 5,
@@ -228,7 +231,7 @@ var createSnowflake = (worldWidth, config, isBackground = false) => {
228
231
  wind: (noise.wind - 0.5) * profile.windScale,
229
232
  opacity,
230
233
  glowOpacity,
231
- wobble: noise.wobblePhase * TAU,
234
+ wobble: initialWobble,
232
235
  wobbleSpeed: noise.wobbleSpeed * profile.wobbleScale + profile.wobbleBase,
233
236
  sizeRatio,
234
237
  isBackground
@@ -344,84 +347,101 @@ var accumulateSide = (sideArray, rectHeight, localY, maxSideHeight, borderRadius
344
347
  }
345
348
  return newMax;
346
349
  };
350
+ var updateSnowflakePosition = (flake, dt) => {
351
+ flake.wobble += flake.wobbleSpeed * dt;
352
+ flake.x += (flake.wind + Math.sin(flake.wobble) * 0.5) * dt;
353
+ flake.y += (flake.speed + Math.cos(flake.wobble * 0.5) * 0.1) * dt;
354
+ };
355
+ var checkSideCollision = (flake, flakeViewportX, flakeViewportY, rect, acc, config) => {
356
+ const isInVerticalBounds = flakeViewportY >= rect.top && flakeViewportY <= rect.bottom;
357
+ if (!isInVerticalBounds || acc.maxSideHeight <= 0) {
358
+ return false;
359
+ }
360
+ const localY = Math.floor(flakeViewportY - rect.top);
361
+ const borderRadius = acc.borderRadius;
362
+ const isInTopCorner = localY < borderRadius;
363
+ const isInBottomCorner = localY > rect.height - borderRadius;
364
+ const isCorner = borderRadius > 0 && (isInTopCorner || isInBottomCorner);
365
+ if (isCorner) {
366
+ return false;
367
+ }
368
+ if (flakeViewportX >= rect.left - 5 && flakeViewportX < rect.left + 3) {
369
+ acc.leftMax = accumulateSide(acc.leftSide, rect.height, localY, acc.maxSideHeight, borderRadius, config, acc.leftMax);
370
+ return true;
371
+ }
372
+ if (flakeViewportX > rect.right - 3 && flakeViewportX <= rect.right + 5) {
373
+ acc.rightMax = accumulateSide(acc.rightSide, rect.height, localY, acc.maxSideHeight, borderRadius, config, acc.rightMax);
374
+ return true;
375
+ }
376
+ return false;
377
+ };
378
+ var checkSurfaceCollision = (flake, flakeViewportX, flakeViewportY, rect, acc, isBottom, config) => {
379
+ if (flakeViewportX < rect.left || flakeViewportX > rect.right) {
380
+ return false;
381
+ }
382
+ const localX = Math.floor(flakeViewportX - rect.left);
383
+ const currentHeight = acc.heights[localX] || 0;
384
+ const maxHeight = acc.maxHeights[localX] || 5;
385
+ const surfaceY = isBottom ? rect.bottom - currentHeight : rect.top - currentHeight;
386
+ if (flakeViewportY < surfaceY || flakeViewportY >= surfaceY + 10 || currentHeight >= maxHeight) {
387
+ return false;
388
+ }
389
+ const shouldAccumulate2 = isBottom ? Math.random() < 0.15 : true;
390
+ if (shouldAccumulate2) {
391
+ const baseSpread = Math.ceil(flake.radius);
392
+ const spread = baseSpread + Math.floor(Math.random() * 2);
393
+ const accumRate = isBottom ? config.ACCUMULATION.BOTTOM_RATE : config.ACCUMULATION.TOP_RATE;
394
+ const centerOffset = Math.floor(Math.random() * 3) - 1;
395
+ for (let dx = -spread; dx <= spread; dx++) {
396
+ if (Math.random() < 0.15) continue;
397
+ const idx = localX + dx + centerOffset;
398
+ if (idx >= 0 && idx < acc.heights.length) {
399
+ const dist = Math.abs(dx);
400
+ const pixelMax = acc.maxHeights[idx] || 5;
401
+ const normDist = dist / spread;
402
+ const falloff = (Math.cos(normDist * Math.PI) + 1) / 2;
403
+ const baseAdd = 0.3 * falloff;
404
+ const randomFactor = 0.8 + Math.random() * 0.4;
405
+ const addHeight = baseAdd * randomFactor * accumRate;
406
+ if (acc.heights[idx] < pixelMax && addHeight > 0) {
407
+ acc.heights[idx] = Math.min(pixelMax, acc.heights[idx] + addHeight);
408
+ }
409
+ }
410
+ }
411
+ if (isBottom) {
412
+ return true;
413
+ }
414
+ }
415
+ return !isBottom;
416
+ };
417
+ var shouldRemoveSnowflake = (flake, landed, worldWidth, worldHeight) => {
418
+ return landed || flake.y > worldHeight + 10 || flake.x < -20 || flake.x > worldWidth + 20;
419
+ };
347
420
  var updateSnowflakes = (snowflakes, elementRects, config, dt, worldWidth, worldHeight) => {
348
421
  const scrollX = window.scrollX;
349
422
  const scrollY = window.scrollY;
350
423
  for (let i = snowflakes.length - 1; i >= 0; i--) {
351
424
  const flake = snowflakes[i];
352
- flake.wobble += flake.wobbleSpeed * dt;
353
- flake.x += (flake.wind + Math.sin(flake.wobble) * 0.5) * dt;
354
- flake.y += (flake.speed + Math.cos(flake.wobble * 0.5) * 0.1) * dt;
425
+ updateSnowflakePosition(flake, dt);
355
426
  let landed = false;
356
- for (const item of elementRects) {
357
- const { rect, acc } = item;
358
- const isBottom = acc.type === VAL_BOTTOM;
427
+ const shouldCheckCollision = Math.random() < config.COLLISION_CHECK_RATE;
428
+ if (shouldCheckCollision) {
359
429
  const flakeViewportX = flake.x - scrollX;
360
430
  const flakeViewportY = flake.y - scrollY;
361
- const isInVerticalBounds = flakeViewportY >= rect.top && flakeViewportY <= rect.bottom;
362
- if (!landed && acc.maxSideHeight > 0 && !isBottom) {
363
- if (isInVerticalBounds) {
364
- const localY = Math.floor(flakeViewportY - rect.top);
365
- const borderRadius = acc.borderRadius;
366
- const isInTopCorner = localY < borderRadius;
367
- const isInBottomCorner = localY > rect.height - borderRadius;
368
- const isCorner = borderRadius > 0 && (isInTopCorner || isInBottomCorner);
369
- if (flakeViewportX >= rect.left - 5 && flakeViewportX < rect.left + 3) {
370
- if (!isCorner) {
371
- acc.leftMax = accumulateSide(acc.leftSide, rect.height, localY, acc.maxSideHeight, borderRadius, config, acc.leftMax);
372
- landed = true;
373
- }
374
- }
375
- if (!landed && flakeViewportX > rect.right - 3 && flakeViewportX <= rect.right + 5) {
376
- if (!isCorner) {
377
- acc.rightMax = accumulateSide(acc.rightSide, rect.height, localY, acc.maxSideHeight, borderRadius, config, acc.rightMax);
378
- landed = true;
379
- }
380
- }
431
+ for (const item of elementRects) {
432
+ const { rect, acc } = item;
433
+ const isBottom = acc.type === VAL_BOTTOM;
434
+ if (!landed && !isBottom) {
435
+ landed = checkSideCollision(flake, flakeViewportX, flakeViewportY, rect, acc, config);
381
436
  if (landed) break;
382
437
  }
383
- }
384
- if (flakeViewportX >= rect.left && flakeViewportX <= rect.right) {
385
- const localX = Math.floor(flakeViewportX - rect.left);
386
- const currentHeight = acc.heights[localX] || 0;
387
- const maxHeight = acc.maxHeights[localX] || 5;
388
- const surfaceY = isBottom ? rect.bottom - currentHeight : rect.top - currentHeight;
389
- if (flakeViewportY >= surfaceY && flakeViewportY < surfaceY + 10 && currentHeight < maxHeight) {
390
- const shouldAccumulate2 = isBottom ? Math.random() < 0.15 : true;
391
- if (shouldAccumulate2) {
392
- const baseSpread = Math.ceil(flake.radius);
393
- const spread = baseSpread + Math.floor(Math.random() * 2);
394
- const accumRate = isBottom ? config.ACCUMULATION.BOTTOM_RATE : config.ACCUMULATION.TOP_RATE;
395
- const centerOffset = Math.floor(Math.random() * 3) - 1;
396
- for (let dx = -spread; dx <= spread; dx++) {
397
- if (Math.random() < 0.15) continue;
398
- const idx = localX + dx + centerOffset;
399
- if (idx >= 0 && idx < acc.heights.length) {
400
- const dist = Math.abs(dx);
401
- const pixelMax = acc.maxHeights[idx] || 5;
402
- const normDist = dist / spread;
403
- const falloff = (Math.cos(normDist * Math.PI) + 1) / 2;
404
- const baseAdd = 0.3 * falloff;
405
- const randomFactor = 0.8 + Math.random() * 0.4;
406
- const addHeight = baseAdd * randomFactor * accumRate;
407
- if (acc.heights[idx] < pixelMax && addHeight > 0) {
408
- acc.heights[idx] = Math.min(pixelMax, acc.heights[idx] + addHeight);
409
- }
410
- }
411
- }
412
- if (isBottom) {
413
- landed = true;
414
- break;
415
- }
416
- }
417
- if (!isBottom) {
418
- landed = true;
419
- break;
420
- }
438
+ if (!landed) {
439
+ landed = checkSurfaceCollision(flake, flakeViewportX, flakeViewportY, rect, acc, isBottom, config);
440
+ if (landed) break;
421
441
  }
422
442
  }
423
443
  }
424
- if (landed || flake.y > worldHeight + 10 || flake.x < -20 || flake.x > worldWidth + 20) {
444
+ if (shouldRemoveSnowflake(flake, landed, worldWidth, worldHeight)) {
425
445
  snowflakes.splice(i, 1);
426
446
  }
427
447
  }
@@ -458,7 +478,85 @@ var meltAndSmoothAccumulation = (elementRects, config, dt) => {
458
478
  }
459
479
  };
460
480
 
461
- // src/utils/snowfall/draw.ts
481
+ // src/hooks/usePerformanceMetrics.ts
482
+ import { useRef, useCallback } from "react";
483
+ function usePerformanceMetrics() {
484
+ const lastFpsSecondRef = useRef(0);
485
+ const framesInSecondRef = useRef(0);
486
+ const currentFpsRef = useRef(0);
487
+ const metricsRef = useRef({
488
+ scanTime: 0,
489
+ rectUpdateTime: 0,
490
+ frameTime: 0,
491
+ rafGap: 0,
492
+ clearTime: 0,
493
+ physicsTime: 0,
494
+ drawTime: 0
495
+ });
496
+ const updateFps = useCallback((now) => {
497
+ const currentSecond = Math.floor(now / 1e3);
498
+ if (currentSecond !== lastFpsSecondRef.current) {
499
+ currentFpsRef.current = framesInSecondRef.current;
500
+ framesInSecondRef.current = 1;
501
+ lastFpsSecondRef.current = currentSecond;
502
+ } else {
503
+ framesInSecondRef.current++;
504
+ }
505
+ }, []);
506
+ const getCurrentFps = useCallback(() => {
507
+ return currentFpsRef.current || framesInSecondRef.current;
508
+ }, []);
509
+ const buildMetrics = useCallback((surfaceCount, flakeCount, maxFlakes) => {
510
+ return {
511
+ fps: currentFpsRef.current || framesInSecondRef.current,
512
+ frameTime: metricsRef.current.frameTime,
513
+ scanTime: metricsRef.current.scanTime,
514
+ rectUpdateTime: metricsRef.current.rectUpdateTime,
515
+ surfaceCount,
516
+ flakeCount,
517
+ maxFlakes,
518
+ rafGap: metricsRef.current.rafGap,
519
+ clearTime: metricsRef.current.clearTime,
520
+ physicsTime: metricsRef.current.physicsTime,
521
+ drawTime: metricsRef.current.drawTime
522
+ };
523
+ }, []);
524
+ return {
525
+ metricsRef,
526
+ updateFps,
527
+ getCurrentFps,
528
+ buildMetrics
529
+ };
530
+ }
531
+
532
+ // src/hooks/useSnowfallCanvas.ts
533
+ import { useRef as useRef2, useCallback as useCallback2 } from "react";
534
+ function useSnowfallCanvas() {
535
+ const canvasRef = useRef2(null);
536
+ const dprRef = useRef2(1);
537
+ const resizeCanvas = useCallback2(() => {
538
+ if (canvasRef.current) {
539
+ const newWidth = window.innerWidth;
540
+ const newHeight = window.innerHeight;
541
+ const dpr = window.devicePixelRatio || 1;
542
+ dprRef.current = dpr;
543
+ canvasRef.current.width = newWidth * dpr;
544
+ canvasRef.current.height = newHeight * dpr;
545
+ canvasRef.current.style.width = `${newWidth}px`;
546
+ canvasRef.current.style.height = `${newHeight}px`;
547
+ }
548
+ }, []);
549
+ return {
550
+ canvasRef,
551
+ dprRef,
552
+ resizeCanvas
553
+ };
554
+ }
555
+
556
+ // src/hooks/useAnimationLoop.ts
557
+ import { useRef as useRef3, useCallback as useCallback3 } from "react";
558
+
559
+ // src/core/draw.ts
462
560
  var ACC_FILL_STYLE = "rgba(255, 255, 255, 0.95)";
463
561
  var ACC_SHADOW_COLOR = "rgba(200, 230, 255, 0.6)";
464
562
  var drawSnowflakes = (ctx, flakes) => {
@@ -567,29 +665,149 @@ var drawSideAccumulations = (ctx, elementRects, scrollX, scrollY) => {
567
665
  ctx.shadowBlur = 0;
568
666
  };
569
667
 
570
- // src/Snowfall.tsx
668
+ // src/hooks/useAnimationLoop.ts
669
+ function useAnimationLoop(params) {
670
+ const animationIdRef = useRef3(0);
671
+ const lastTimeRef = useRef3(0);
672
+ const lastMetricsUpdateRef = useRef3(0);
673
+ const elementRectsRef = useRef3([]);
674
+ const dirtyRectsRef = useRef3(true);
675
+ const animate = useCallback3((currentTime) => {
676
+ const {
677
+ canvasRef,
678
+ dprRef,
679
+ snowflakesRef,
680
+ accumulationRef,
681
+ isEnabledRef,
682
+ physicsConfigRef,
683
+ metricsRef,
684
+ updateFps,
685
+ getCurrentFps,
686
+ buildMetrics,
687
+ setMetricsRef
688
+ } = params;
689
+ const canvas = canvasRef.current;
690
+ if (!canvas) {
691
+ animationIdRef.current = requestAnimationFrame(animate);
692
+ return;
693
+ }
694
+ const ctx = canvas.getContext("2d");
695
+ if (!ctx) {
696
+ animationIdRef.current = requestAnimationFrame(animate);
697
+ return;
698
+ }
699
+ if (lastTimeRef.current === 0) {
700
+ lastTimeRef.current = currentTime;
701
+ animationIdRef.current = requestAnimationFrame(animate);
702
+ return;
703
+ }
704
+ const deltaTime = Math.min(currentTime - lastTimeRef.current, 50);
705
+ const now = performance.now();
706
+ updateFps(now);
707
+ metricsRef.current.rafGap = currentTime - lastTimeRef.current;
708
+ lastTimeRef.current = currentTime;
709
+ const dt = deltaTime / 16.67;
710
+ const frameStartTime = performance.now();
711
+ const clearStart = performance.now();
712
+ const dpr = dprRef.current;
713
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
714
+ ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
715
+ const scrollX = window.scrollX;
716
+ const scrollY = window.scrollY;
717
+ ctx.translate(-scrollX, -scrollY);
718
+ metricsRef.current.clearTime = performance.now() - clearStart;
719
+ const snowflakes = snowflakesRef.current;
720
+ if (dirtyRectsRef.current) {
721
+ const rectStart = performance.now();
722
+ elementRectsRef.current = getElementRects(accumulationRef.current);
723
+ metricsRef.current.rectUpdateTime = performance.now() - rectStart;
724
+ dirtyRectsRef.current = false;
725
+ }
726
+ const physicsStart = performance.now();
727
+ meltAndSmoothAccumulation(elementRectsRef.current, physicsConfigRef.current, dt);
728
+ updateSnowflakes(
729
+ snowflakes,
730
+ elementRectsRef.current,
731
+ physicsConfigRef.current,
732
+ dt,
733
+ document.documentElement.scrollWidth,
734
+ document.documentElement.scrollHeight
735
+ );
736
+ metricsRef.current.physicsTime = performance.now() - physicsStart;
737
+ const drawStart = performance.now();
738
+ drawSnowflakes(ctx, snowflakes);
739
+ if (isEnabledRef.current && snowflakes.length < physicsConfigRef.current.MAX_FLAKES) {
740
+ const currentFps = getCurrentFps();
741
+ const shouldSpawn = currentFps >= 40 || Math.random() < 0.2;
742
+ if (shouldSpawn) {
743
+ const isBackground = Math.random() < 0.4;
744
+ snowflakes.push(createSnowflake(document.documentElement.scrollWidth, physicsConfigRef.current, isBackground));
745
+ }
746
+ }
747
+ const viewportWidth = window.innerWidth;
748
+ const viewportHeight = window.innerHeight;
749
+ const visibleRects = elementRectsRef.current.filter(
750
+ ({ rect }) => rect.right >= 0 && rect.left <= viewportWidth && rect.bottom >= 0 && rect.top <= viewportHeight
751
+ );
752
+ if (visibleRects.length > 0) {
753
+ drawAccumulations(ctx, visibleRects, scrollX, scrollY);
754
+ drawSideAccumulations(ctx, visibleRects, scrollX, scrollY);
755
+ }
756
+ metricsRef.current.drawTime = performance.now() - drawStart;
757
+ metricsRef.current.frameTime = performance.now() - frameStartTime;
758
+ if (currentTime - lastMetricsUpdateRef.current > 500) {
759
+ setMetricsRef.current(buildMetrics(
760
+ accumulationRef.current.size,
761
+ snowflakes.length,
762
+ physicsConfigRef.current.MAX_FLAKES
763
+ ));
764
+ lastMetricsUpdateRef.current = currentTime;
765
+ }
766
+ animationIdRef.current = requestAnimationFrame(animate);
767
+ }, [params]);
768
+ const start = useCallback3(() => {
769
+ lastTimeRef.current = 0;
770
+ lastMetricsUpdateRef.current = 0;
771
+ animationIdRef.current = requestAnimationFrame(animate);
772
+ }, [animate]);
773
+ const stop = useCallback3(() => {
774
+ cancelAnimationFrame(animationIdRef.current);
775
+ }, []);
776
+ const markRectsDirty = useCallback3(() => {
777
+ dirtyRectsRef.current = true;
778
+ }, []);
779
+ return {
780
+ start,
781
+ stop,
782
+ markRectsDirty
783
+ };
784
+ }
785
+
786
+ // src/components/Snowfall.tsx
571
787
  import { Fragment, jsx as jsx2 } from "react/jsx-runtime";
572
788
  function Snowfall() {
573
789
  const { isEnabled, physicsConfig, setMetrics } = useSnowfall();
574
- const isEnabledRef = useRef(isEnabled);
575
- const physicsConfigRef = useRef(physicsConfig);
576
- const setMetricsRef = useRef(setMetrics);
790
+ const isEnabledRef = useRef4(isEnabled);
791
+ const physicsConfigRef = useRef4(physicsConfig);
792
+ const setMetricsRef = useRef4(setMetrics);
577
793
  const [isMounted, setIsMounted] = useState2(false);
578
794
  const [isVisible, setIsVisible] = useState2(false);
579
- const canvasRef = useRef(null);
580
- const snowflakesRef = useRef([]);
581
- const accumulationRef = useRef(/* @__PURE__ */ new Map());
582
- const animationIdRef = useRef(0);
583
- const dprRef = useRef(1);
584
- const fpsFrames = useRef([]);
585
- const metricsRef = useRef({
586
- scanTime: 0,
587
- rectUpdateTime: 0,
588
- frameTime: 0,
589
- rafGap: 0,
590
- clearTime: 0,
591
- physicsTime: 0,
592
- drawTime: 0
795
+ const snowflakesRef = useRef4([]);
796
+ const accumulationRef = useRef4(/* @__PURE__ */ new Map());
797
+ const { canvasRef, dprRef, resizeCanvas } = useSnowfallCanvas();
798
+ const { metricsRef, updateFps, getCurrentFps, buildMetrics } = usePerformanceMetrics();
799
+ const { start: startAnimation, stop: stopAnimation, markRectsDirty } = useAnimationLoop({
800
+ canvasRef,
801
+ dprRef,
802
+ snowflakesRef,
803
+ accumulationRef,
804
+ isEnabledRef,
805
+ physicsConfigRef,
806
+ metricsRef,
807
+ updateFps,
808
+ getCurrentFps,
809
+ buildMetrics,
810
+ setMetricsRef
593
811
  });
594
812
  useEffect(() => {
595
813
  requestAnimationFrame(() => setIsMounted(true));
@@ -609,23 +827,8 @@ function Snowfall() {
609
827
  if (!canvas) return;
610
828
  const ctx = canvas.getContext("2d");
611
829
  if (!ctx) return;
612
- const resizeCanvas = () => {
613
- if (canvasRef.current) {
614
- const newWidth = window.innerWidth;
615
- const newHeight = window.innerHeight;
616
- const dpr = window.devicePixelRatio || 1;
617
- dprRef.current = dpr;
618
- canvasRef.current.width = newWidth * dpr;
619
- canvasRef.current.height = newHeight * dpr;
620
- canvasRef.current.style.width = `${newWidth}px`;
621
- canvasRef.current.style.height = `${newHeight}px`;
622
- }
623
- };
624
830
  resizeCanvas();
625
- const windowResizeObserver = new ResizeObserver(() => {
626
- resizeCanvas();
627
- });
628
- windowResizeObserver.observe(document.body);
831
+ snowflakesRef.current = [];
629
832
  const surfaceObserver = new ResizeObserver((entries) => {
630
833
  let needsUpdate = false;
631
834
  for (const entry of entries) {
@@ -638,7 +841,6 @@ function Snowfall() {
638
841
  initAccumulationWrapper();
639
842
  }
640
843
  });
641
- snowflakesRef.current = [];
642
844
  const initAccumulationWrapper = () => {
643
845
  const scanStart = performance.now();
644
846
  initializeAccumulation(accumulationRef.current, physicsConfigRef.current);
@@ -647,103 +849,48 @@ function Snowfall() {
647
849
  surfaceObserver.observe(el);
648
850
  }
649
851
  metricsRef.current.scanTime = performance.now() - scanStart;
852
+ markRectsDirty();
650
853
  };
651
854
  initAccumulationWrapper();
652
855
  requestAnimationFrame(() => {
653
856
  if (isMounted) setIsVisible(true);
654
857
  });
655
- let lastTime = 0;
656
- let lastMetricsUpdate = 0;
657
- let elementRects = [];
658
- const animate = (currentTime) => {
659
- if (lastTime === 0) {
660
- lastTime = currentTime;
661
- animationIdRef.current = requestAnimationFrame(animate);
662
- return;
663
- }
664
- const deltaTime = Math.min(currentTime - lastTime, 50);
665
- const now = performance.now();
666
- fpsFrames.current.push(now);
667
- fpsFrames.current = fpsFrames.current.filter((t) => now - t < 1e3);
668
- metricsRef.current.rafGap = currentTime - lastTime;
669
- lastTime = currentTime;
670
- const dt = deltaTime / 16.67;
671
- const frameStartTime = performance.now();
672
- const clearStart = performance.now();
673
- const dpr = dprRef.current;
674
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
675
- ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
676
- const scrollX = window.scrollX;
677
- const scrollY = window.scrollY;
678
- ctx.translate(-scrollX, -scrollY);
679
- metricsRef.current.clearTime = performance.now() - clearStart;
680
- const snowflakes = snowflakesRef.current;
681
- const rectStart = performance.now();
682
- elementRects = getElementRects(accumulationRef.current);
683
- metricsRef.current.rectUpdateTime = performance.now() - rectStart;
684
- const physicsStart = performance.now();
685
- meltAndSmoothAccumulation(elementRects, physicsConfigRef.current, dt);
686
- updateSnowflakes(
687
- snowflakes,
688
- elementRects,
689
- physicsConfigRef.current,
690
- dt,
691
- document.documentElement.scrollWidth,
692
- document.documentElement.scrollHeight
693
- );
694
- metricsRef.current.physicsTime = performance.now() - physicsStart;
695
- const drawStart = performance.now();
696
- drawSnowflakes(ctx, snowflakes);
697
- if (isEnabledRef.current && snowflakes.length < physicsConfigRef.current.MAX_FLAKES) {
698
- const currentFps = fpsFrames.current.length;
699
- const shouldSpawn = currentFps >= 40 || Math.random() < 0.2;
700
- if (shouldSpawn) {
701
- const isBackground = Math.random() < 0.4;
702
- snowflakes.push(createSnowflake(document.documentElement.scrollWidth, physicsConfigRef.current, isBackground));
703
- }
704
- }
705
- const viewportWidth = window.innerWidth;
706
- const viewportHeight = window.innerHeight;
707
- const visibleRects = elementRects.filter(
708
- ({ rect }) => rect.right >= 0 && rect.left <= viewportWidth && rect.bottom >= 0 && rect.top <= viewportHeight
709
- );
710
- if (visibleRects.length > 0) {
711
- drawAccumulations(ctx, visibleRects, scrollX, scrollY);
712
- drawSideAccumulations(ctx, visibleRects, scrollX, scrollY);
713
- }
714
- metricsRef.current.drawTime = performance.now() - drawStart;
715
- metricsRef.current.frameTime = performance.now() - frameStartTime;
716
- if (currentTime - lastMetricsUpdate > 500) {
717
- setMetricsRef.current({
718
- fps: fpsFrames.current.length,
719
- frameTime: metricsRef.current.frameTime,
720
- scanTime: metricsRef.current.scanTime,
721
- rectUpdateTime: metricsRef.current.rectUpdateTime,
722
- surfaceCount: accumulationRef.current.size,
723
- flakeCount: snowflakes.length,
724
- maxFlakes: physicsConfigRef.current.MAX_FLAKES,
725
- rafGap: metricsRef.current.rafGap,
726
- clearTime: metricsRef.current.clearTime,
727
- physicsTime: metricsRef.current.physicsTime,
728
- drawTime: metricsRef.current.drawTime
729
- });
730
- lastMetricsUpdate = currentTime;
731
- }
732
- animationIdRef.current = requestAnimationFrame(animate);
733
- };
734
- animationIdRef.current = requestAnimationFrame(animate);
858
+ startAnimation();
735
859
  const handleResize = () => {
736
860
  resizeCanvas();
737
861
  accumulationRef.current.clear();
738
862
  initAccumulationWrapper();
863
+ markRectsDirty();
739
864
  };
740
865
  window.addEventListener("resize", handleResize);
741
- const checkInterval = setInterval(initAccumulationWrapper, 5e3);
866
+ const windowResizeObserver = new ResizeObserver(() => {
867
+ resizeCanvas();
868
+ });
869
+ windowResizeObserver.observe(document.body);
870
+ const mutationObserver = new MutationObserver((mutations) => {
871
+ let hasStructuralChange = false;
872
+ for (const mutation of mutations) {
873
+ if (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0) {
874
+ hasStructuralChange = true;
875
+ break;
876
+ }
877
+ }
878
+ if (hasStructuralChange) {
879
+ const scanStart = performance.now();
880
+ initializeAccumulation(accumulationRef.current, physicsConfigRef.current);
881
+ metricsRef.current.scanTime = performance.now() - scanStart;
882
+ markRectsDirty();
883
+ }
884
+ });
885
+ mutationObserver.observe(document.body, {
886
+ childList: true,
887
+ subtree: true
888
+ });
742
889
  return () => {
743
- cancelAnimationFrame(animationIdRef.current);
890
+ stopAnimation();
744
891
  window.removeEventListener("resize", handleResize);
745
- clearInterval(checkInterval);
746
892
  windowResizeObserver.disconnect();
893
+ mutationObserver.disconnect();
747
894
  surfaceObserver.disconnect();
748
895
  };
749
896
  }, [isMounted]);
@@ -768,7 +915,7 @@ function Snowfall() {
768
915
  ) });
769
916
  }
770
917
 
771
- // src/DebugPanel.tsx
918
+ // src/components/DebugPanel.tsx
772
919
  import { useEffect as useEffect2, useState as useState3 } from "react";
773
920
  import { Fragment as Fragment2, jsx as jsx3, jsxs } from "react/jsx-runtime";
774
921
  function DebugPanel({ defaultOpen = true }) {