@hdcodedev/snowfall 1.0.4 → 1.0.6

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
@@ -23,15 +23,21 @@ var DEFAULT_PHYSICS = {
23
23
  FLAKE_SIZE: {
24
24
  MIN: 0.5,
25
25
  MAX: 1.6
26
- }
26
+ },
27
+ MAX_SURFACES: 15
27
28
  };
28
29
  var SnowfallContext = createContext(void 0);
29
- function SnowfallProvider({ children }) {
30
+ function SnowfallProvider({ children, initialDebug = false }) {
30
31
  const [isEnabled, setIsEnabled] = useState(true);
31
32
  const [physicsConfig, setPhysicsConfig] = useState(DEFAULT_PHYSICS);
33
+ const [debugMode, setDebugMode] = useState(initialDebug);
34
+ const [metrics, setMetrics] = useState(null);
32
35
  const toggleSnow = () => {
33
36
  setIsEnabled((prev) => !prev);
34
37
  };
38
+ const toggleDebug = () => {
39
+ setDebugMode((prev) => !prev);
40
+ };
35
41
  const updatePhysicsConfig = (config) => {
36
42
  setPhysicsConfig((prev) => ({
37
43
  ...prev,
@@ -58,7 +64,11 @@ function SnowfallProvider({ children }) {
58
64
  toggleSnow,
59
65
  physicsConfig,
60
66
  updatePhysicsConfig,
61
- resetPhysics
67
+ resetPhysics,
68
+ debugMode,
69
+ toggleDebug,
70
+ metrics,
71
+ setMetrics
62
72
  }, children });
63
73
  }
64
74
  function useSnowfall() {
@@ -75,13 +85,15 @@ var VAL_IGNORE = "ignore";
75
85
  var VAL_TOP = "top";
76
86
  var VAL_BOTTOM = "bottom";
77
87
  var TAG_HEADER = "header";
88
+ var TAG_FOOTER = "footer";
78
89
  var ROLE_BANNER = "banner";
90
+ var ROLE_CONTENTINFO = "contentinfo";
79
91
 
80
92
  // src/utils/snowfall/dom.ts
81
93
  var BOTTOM_TAGS = [TAG_HEADER];
82
94
  var BOTTOM_ROLES = [ROLE_BANNER];
83
- var AUTO_DETECT_TAGS = ["header", "footer", "article", "section", "aside", "nav"];
84
- var AUTO_DETECT_ROLES = ['[role="banner"]', '[role="contentinfo"]', '[role="main"]'];
95
+ var AUTO_DETECT_TAGS = [TAG_HEADER, TAG_FOOTER, "article", "section", "aside", "nav"];
96
+ var AUTO_DETECT_ROLES = [`[role="${ROLE_BANNER}"]`, `[role="${ROLE_CONTENTINFO}"]`, '[role="main"]'];
85
97
  var AUTO_DETECT_CLASSES = [
86
98
  ".card",
87
99
  '[class*="card"]',
@@ -97,21 +109,21 @@ var getElementType = (el) => {
97
109
  if (role && BOTTOM_ROLES.includes(role)) return VAL_BOTTOM;
98
110
  return VAL_TOP;
99
111
  };
100
- var shouldAccumulate = (el) => {
112
+ var shouldAccumulate = (el, precomputedStyle) => {
101
113
  if (el.getAttribute(ATTR_SNOWFALL) === VAL_IGNORE) return false;
102
114
  if (el.hasAttribute(ATTR_SNOWFALL)) return true;
103
- const styles = window.getComputedStyle(el);
104
- const rect = el.getBoundingClientRect();
115
+ const styles = precomputedStyle || window.getComputedStyle(el);
105
116
  const isVisible = styles.display !== "none" && styles.visibility !== "hidden" && parseFloat(styles.opacity) > 0.1;
106
117
  if (!isVisible) return false;
107
118
  const bgColor = styles.backgroundColor;
108
119
  const hasBackground = bgColor !== "rgba(0, 0, 0, 0)" && bgColor !== "transparent";
109
- const hasBorder = parseFloat(styles.borderWidth) > 0 || styles.borderStyle !== "none";
120
+ const hasBorder = parseFloat(styles.borderWidth) > 0 && styles.borderColor !== "transparent" && styles.borderColor !== "rgba(0, 0, 0, 0)" && styles.borderStyle !== "none";
110
121
  const hasBoxShadow = styles.boxShadow !== "none";
111
- const hasBorderRadius = parseFloat(styles.borderRadius) > 0;
112
- return hasBackground || hasBorder || hasBoxShadow || hasBorderRadius;
122
+ const hasFilter = styles.filter !== "none" && styles.filter.includes("drop-shadow");
123
+ const hasBackdropFilter = styles.backdropFilter !== "none";
124
+ return hasBackground || hasBorder || hasBoxShadow || hasFilter || hasBackdropFilter;
113
125
  };
114
- var getAccumulationSurfaces = () => {
126
+ var getAccumulationSurfaces = (maxSurfaces = 5) => {
115
127
  const surfaces = [];
116
128
  const seen = /* @__PURE__ */ new Set();
117
129
  const candidates = document.querySelectorAll(
@@ -122,44 +134,31 @@ var getAccumulationSurfaces = () => {
122
134
  ...AUTO_DETECT_CLASSES
123
135
  ].join(", ")
124
136
  );
125
- candidates.forEach((el) => {
126
- if (seen.has(el)) return;
127
- const rect = el.getBoundingClientRect();
137
+ for (const el of candidates) {
138
+ if (surfaces.length >= maxSurfaces) break;
139
+ if (seen.has(el)) continue;
128
140
  const manualOverride = el.getAttribute(ATTR_SNOWFALL);
129
- if (manualOverride === VAL_IGNORE) return;
141
+ if (manualOverride === VAL_IGNORE) continue;
130
142
  const isManuallyIncluded = manualOverride !== null;
131
143
  const styles = window.getComputedStyle(el);
132
144
  const isVisible = styles.display !== "none" && styles.visibility !== "hidden" && parseFloat(styles.opacity) > 0.1;
133
- if (!isVisible && !isManuallyIncluded) return;
145
+ if (!isVisible && !isManuallyIncluded) continue;
146
+ const rect = el.getBoundingClientRect();
134
147
  const hasSize = rect.width >= 100 && rect.height >= 50;
135
- if (!hasSize && !isManuallyIncluded) return;
148
+ if (!hasSize && !isManuallyIncluded) continue;
136
149
  const isFullPageWrapper = rect.top <= 10 && rect.height >= window.innerHeight * 0.9;
137
150
  const isBottomTag = BOTTOM_TAGS.includes(el.tagName.toLowerCase());
138
151
  const isBottomRole = BOTTOM_ROLES.includes(el.getAttribute("role") || "");
139
152
  const isBottomSurface = isBottomTag || isBottomRole || manualOverride === VAL_BOTTOM;
140
- if (isFullPageWrapper && !isBottomSurface && !isManuallyIncluded) return;
141
- let isFixed = false;
142
- let currentEl = el;
143
- while (currentEl && currentEl !== document.body) {
144
- const style = window.getComputedStyle(currentEl);
145
- if (style.position === "fixed" || style.position === "sticky") {
146
- isFixed = true;
147
- break;
148
- }
149
- currentEl = currentEl.parentElement;
150
- }
151
- if (shouldAccumulate(el)) {
153
+ if (isFullPageWrapper && !isBottomSurface && !isManuallyIncluded) continue;
154
+ if (shouldAccumulate(el, styles)) {
152
155
  let type = getElementType(el);
153
- if (manualOverride === VAL_BOTTOM) {
154
- type = VAL_BOTTOM;
155
- } else if (manualOverride === VAL_TOP) {
156
- type = VAL_TOP;
157
- }
158
- surfaces.push({ el, type, isFixed });
156
+ if (manualOverride === VAL_BOTTOM) type = VAL_BOTTOM;
157
+ else if (manualOverride === VAL_TOP) type = VAL_TOP;
158
+ surfaces.push({ el, type });
159
159
  seen.add(el);
160
160
  }
161
- });
162
- console.log(`[Snowfall] Auto-detection found ${surfaces.length} surfaces`);
161
+ }
163
162
  return surfaces;
164
163
  };
165
164
  var getElementRects = (accumulationMap) => {
@@ -167,31 +166,18 @@ var getElementRects = (accumulationMap) => {
167
166
  for (const [el, acc] of accumulationMap.entries()) {
168
167
  if (!el.isConnected) continue;
169
168
  const rect = el.getBoundingClientRect();
170
- const absoluteRect = {
171
- left: rect.left + window.scrollX,
172
- right: rect.right + window.scrollX,
173
- top: rect.top + window.scrollY,
174
- bottom: rect.bottom + window.scrollY,
175
- width: rect.width,
176
- height: rect.height,
177
- x: rect.x,
178
- // Note: these are strictly viewport relative in DOMRect usually,
179
- // but we just need consistent absolute coords for physics
180
- y: rect.y,
181
- toJSON: rect.toJSON
182
- };
183
- elementRects.push({ el, rect: absoluteRect, acc });
169
+ elementRects.push({ el, rect, acc });
184
170
  }
185
171
  return elementRects;
186
172
  };
187
173
 
188
174
  // src/utils/snowfall/physics.ts
189
- var createSnowflake = (canvasWidth, config, isBackground = false) => {
175
+ var createSnowflake = (worldWidth, config, isBackground = false) => {
190
176
  if (isBackground) {
191
177
  const sizeRatio = Math.random();
192
178
  const radius = config.FLAKE_SIZE.MIN * 0.6 + sizeRatio * (config.FLAKE_SIZE.MAX - config.FLAKE_SIZE.MIN) * 0.4;
193
179
  return {
194
- x: Math.random() * canvasWidth,
180
+ x: Math.random() * worldWidth,
195
181
  y: window.scrollY - 5,
196
182
  radius,
197
183
  speed: radius * 0.3 + Math.random() * 0.2 + 0.2,
@@ -206,7 +192,7 @@ var createSnowflake = (canvasWidth, config, isBackground = false) => {
206
192
  const sizeRatio = Math.random();
207
193
  const radius = config.FLAKE_SIZE.MIN + sizeRatio * (config.FLAKE_SIZE.MAX - config.FLAKE_SIZE.MIN);
208
194
  return {
209
- x: Math.random() * canvasWidth,
195
+ x: Math.random() * worldWidth,
210
196
  y: window.scrollY - 5,
211
197
  radius,
212
198
  speed: radius * 0.5 + Math.random() * 0.3 + 0.5,
@@ -220,20 +206,19 @@ var createSnowflake = (canvasWidth, config, isBackground = false) => {
220
206
  }
221
207
  };
222
208
  var initializeAccumulation = (accumulationMap, config) => {
223
- const elements = getAccumulationSurfaces();
209
+ const elements = getAccumulationSurfaces(config.MAX_SURFACES);
224
210
  for (const [el] of accumulationMap.entries()) {
225
211
  if (!el.isConnected) {
226
212
  accumulationMap.delete(el);
227
213
  }
228
214
  }
229
- elements.forEach(({ el, type, isFixed }) => {
215
+ elements.forEach(({ el, type }) => {
230
216
  const existing = accumulationMap.get(el);
231
217
  const rect = el.getBoundingClientRect();
232
218
  const width = Math.ceil(rect.width);
233
219
  const isBottom = type === VAL_BOTTOM;
234
220
  if (existing && existing.heights.length === width) {
235
221
  existing.type = type;
236
- existing.isFixed = isFixed;
237
222
  if (existing.borderRadius !== void 0) {
238
223
  const styleBuffer = window.getComputedStyle(el);
239
224
  existing.borderRadius = parseFloat(styleBuffer.borderTopLeftRadius) || 0;
@@ -271,8 +256,7 @@ var initializeAccumulation = (accumulationMap, config) => {
271
256
  rightSide: existing?.rightSide.length === height ? existing.rightSide : new Array(height).fill(0),
272
257
  maxSideHeight: isBottom ? 0 : config.MAX_DEPTH.SIDE,
273
258
  borderRadius,
274
- type,
275
- isFixed
259
+ type
276
260
  });
277
261
  });
278
262
  };
@@ -291,30 +275,35 @@ var accumulateSide = (sideArray, rectHeight, localY, maxSideHeight, borderRadius
291
275
  }
292
276
  }
293
277
  };
294
- var updateSnowflakes = (snowflakes, elementRects, config, dt, canvasWidth, canvasHeight) => {
278
+ var updateSnowflakes = (snowflakes, elementRects, config, dt, worldWidth, worldHeight) => {
279
+ const scrollX = window.scrollX;
280
+ const scrollY = window.scrollY;
295
281
  for (let i = snowflakes.length - 1; i >= 0; i--) {
296
282
  const flake = snowflakes[i];
297
283
  flake.wobble += flake.wobbleSpeed * dt;
298
284
  flake.x += (flake.wind + Math.sin(flake.wobble) * 0.5) * dt;
299
285
  flake.y += (flake.speed + Math.cos(flake.wobble * 0.5) * 0.1) * dt;
300
286
  let landed = false;
301
- for (const { rect, acc } of elementRects) {
287
+ for (const item of elementRects) {
288
+ const { rect, acc } = item;
302
289
  const isBottom = acc.type === VAL_BOTTOM;
290
+ const flakeViewportX = flake.x - scrollX;
291
+ const flakeViewportY = flake.y - scrollY;
292
+ const isInVerticalBounds = flakeViewportY >= rect.top && flakeViewportY <= rect.bottom;
303
293
  if (!landed && acc.maxSideHeight > 0 && !isBottom) {
304
- const isInVerticalBounds = flake.y >= rect.top && flake.y <= rect.bottom;
305
294
  if (isInVerticalBounds) {
306
- const localY = Math.floor(flake.y - rect.top);
295
+ const localY = Math.floor(flakeViewportY - rect.top);
307
296
  const borderRadius = acc.borderRadius;
308
297
  const isInTopCorner = localY < borderRadius;
309
298
  const isInBottomCorner = localY > rect.height - borderRadius;
310
299
  const isCorner = borderRadius > 0 && (isInTopCorner || isInBottomCorner);
311
- if (flake.x >= rect.left - 5 && flake.x < rect.left + 3) {
300
+ if (flakeViewportX >= rect.left - 5 && flakeViewportX < rect.left + 3) {
312
301
  if (!isCorner) {
313
302
  accumulateSide(acc.leftSide, rect.height, localY, acc.maxSideHeight, borderRadius, config);
314
303
  landed = true;
315
304
  }
316
305
  }
317
- if (!landed && flake.x > rect.right - 3 && flake.x <= rect.right + 5) {
306
+ if (!landed && flakeViewportX > rect.right - 3 && flakeViewportX <= rect.right + 5) {
318
307
  if (!isCorner) {
319
308
  accumulateSide(acc.rightSide, rect.height, localY, acc.maxSideHeight, borderRadius, config);
320
309
  landed = true;
@@ -323,12 +312,12 @@ var updateSnowflakes = (snowflakes, elementRects, config, dt, canvasWidth, canva
323
312
  if (landed) break;
324
313
  }
325
314
  }
326
- if (flake.x >= rect.left && flake.x <= rect.right) {
327
- const localX = Math.floor(flake.x - rect.left);
315
+ if (flakeViewportX >= rect.left && flakeViewportX <= rect.right) {
316
+ const localX = Math.floor(flakeViewportX - rect.left);
328
317
  const currentHeight = acc.heights[localX] || 0;
329
318
  const maxHeight = acc.maxHeights[localX] || 5;
330
319
  const surfaceY = isBottom ? rect.bottom - currentHeight : rect.top - currentHeight;
331
- if (flake.y >= surfaceY && flake.y < surfaceY + 10 && currentHeight < maxHeight) {
320
+ if (flakeViewportY >= surfaceY && flakeViewportY < surfaceY + 10 && currentHeight < maxHeight) {
332
321
  const shouldAccumulate2 = isBottom ? Math.random() < 0.15 : true;
333
322
  if (shouldAccumulate2) {
334
323
  const baseSpread = Math.ceil(flake.radius);
@@ -363,7 +352,7 @@ var updateSnowflakes = (snowflakes, elementRects, config, dt, canvasWidth, canva
363
352
  }
364
353
  }
365
354
  }
366
- if (landed || flake.y > canvasHeight + 10 || flake.x < -20 || flake.x > canvasWidth + 20) {
355
+ if (landed || flake.y > worldHeight + 10 || flake.x < -20 || flake.x > worldWidth + 20) {
367
356
  snowflakes.splice(i, 1);
368
357
  }
369
358
  }
@@ -401,7 +390,7 @@ var drawSnowflake = (ctx, flake) => {
401
390
  ctx.fillStyle = `rgba(255, 255, 255, ${flake.opacity * 0.2})`;
402
391
  ctx.fill();
403
392
  };
404
- var drawAccumulations = (ctx, fixedCtx, elementRects) => {
393
+ var drawAccumulations = (ctx, elementRects) => {
405
394
  const setupCtx = (c) => {
406
395
  c.fillStyle = "rgba(255, 255, 255, 0.95)";
407
396
  c.shadowColor = "rgba(200, 230, 255, 0.6)";
@@ -409,15 +398,13 @@ var drawAccumulations = (ctx, fixedCtx, elementRects) => {
409
398
  c.shadowOffsetY = -1;
410
399
  };
411
400
  setupCtx(ctx);
412
- if (fixedCtx) setupCtx(fixedCtx);
413
- const scrollX = window.scrollX;
414
- const scrollY = window.scrollY;
415
- for (const { rect, acc } of elementRects) {
401
+ const currentScrollX = window.scrollX;
402
+ const currentScrollY = window.scrollY;
403
+ for (const item of elementRects) {
404
+ const { rect, acc } = item;
416
405
  if (!acc.heights.some((h) => h > 0.1)) continue;
417
- const useFixed = acc.isFixed && fixedCtx;
418
- const targetCtx = useFixed ? fixedCtx : ctx;
419
- const dx = useFixed ? -scrollX : 0;
420
- const dy = useFixed ? -scrollY : 0;
406
+ const dx = currentScrollX;
407
+ const dy = currentScrollY;
421
408
  const isBottom = acc.type === VAL_BOTTOM;
422
409
  const baseY = isBottom ? rect.bottom - 1 : rect.top + 1;
423
410
  const borderRadius = acc.borderRadius;
@@ -433,7 +420,7 @@ var drawAccumulations = (ctx, fixedCtx, elementRects) => {
433
420
  }
434
421
  return offset;
435
422
  };
436
- targetCtx.beginPath();
423
+ ctx.beginPath();
437
424
  let first = true;
438
425
  const step = 2;
439
426
  const len = acc.heights.length;
@@ -442,10 +429,10 @@ var drawAccumulations = (ctx, fixedCtx, elementRects) => {
442
429
  const px = rect.left + x + dx;
443
430
  const py = baseY - height + getCurveOffset(x) + dy;
444
431
  if (first) {
445
- targetCtx.moveTo(px, py);
432
+ ctx.moveTo(px, py);
446
433
  first = false;
447
434
  } else {
448
- targetCtx.lineTo(px, py);
435
+ ctx.lineTo(px, py);
449
436
  }
450
437
  }
451
438
  if ((len - 1) % step !== 0) {
@@ -453,50 +440,44 @@ var drawAccumulations = (ctx, fixedCtx, elementRects) => {
453
440
  const height = acc.heights[x] || 0;
454
441
  const px = rect.left + x + dx;
455
442
  const py = baseY - height + getCurveOffset(x) + dy;
456
- targetCtx.lineTo(px, py);
443
+ ctx.lineTo(px, py);
457
444
  }
458
445
  for (let x = len - 1; x >= 0; x -= step) {
459
446
  const px = rect.left + x + dx;
460
447
  const py = baseY + getCurveOffset(x) + dy;
461
- targetCtx.lineTo(px, py);
448
+ ctx.lineTo(px, py);
462
449
  }
463
450
  const startX = 0;
464
451
  const startPx = rect.left + startX + dx;
465
452
  const startPy = baseY + getCurveOffset(startX) + dy;
466
- targetCtx.lineTo(startPx, startPy);
467
- targetCtx.closePath();
468
- targetCtx.fill();
453
+ ctx.lineTo(startPx, startPy);
454
+ ctx.closePath();
455
+ ctx.fill();
469
456
  }
470
457
  ctx.shadowBlur = 0;
471
458
  ctx.shadowOffsetY = 0;
472
- if (fixedCtx) {
473
- fixedCtx.shadowBlur = 0;
474
- fixedCtx.shadowOffsetY = 0;
475
- }
476
459
  };
477
- var drawSideAccumulations = (ctx, fixedCtx, elementRects) => {
460
+ var drawSideAccumulations = (ctx, elementRects) => {
478
461
  const setupCtx = (c) => {
479
462
  c.fillStyle = "rgba(255, 255, 255, 0.95)";
480
463
  c.shadowColor = "rgba(200, 230, 255, 0.6)";
481
464
  c.shadowBlur = 3;
482
465
  };
483
466
  setupCtx(ctx);
484
- if (fixedCtx) setupCtx(fixedCtx);
485
- const scrollX = window.scrollX;
486
- const scrollY = window.scrollY;
487
- for (const { rect, acc } of elementRects) {
467
+ const currentScrollX = window.scrollX;
468
+ const currentScrollY = window.scrollY;
469
+ for (const item of elementRects) {
470
+ const { rect, acc } = item;
488
471
  if (acc.maxSideHeight === 0) continue;
489
472
  const hasLeftSnow = acc.leftSide.some((h) => h > 0.3);
490
473
  const hasRightSnow = acc.rightSide.some((h) => h > 0.3);
491
474
  if (!hasLeftSnow && !hasRightSnow) continue;
492
- const useFixed = acc.isFixed && fixedCtx;
493
- const targetCtx = useFixed ? fixedCtx : ctx;
494
- const dx = useFixed ? -scrollX : 0;
495
- const dy = useFixed ? -scrollY : 0;
475
+ const dx = currentScrollX;
476
+ const dy = currentScrollY;
496
477
  const drawSide = (sideArray, isLeft) => {
497
- targetCtx.beginPath();
478
+ ctx.beginPath();
498
479
  const baseX = isLeft ? rect.left : rect.right;
499
- targetCtx.moveTo(baseX + dx, rect.top + dy);
480
+ ctx.moveTo(baseX + dx, rect.top + dy);
500
481
  for (let y = 0; y < sideArray.length; y += 2) {
501
482
  const width = sideArray[y] || 0;
502
483
  const nextY = Math.min(y + 2, sideArray.length - 1);
@@ -509,33 +490,42 @@ var drawSideAccumulations = (ctx, fixedCtx, elementRects) => {
509
490
  const nRatio = nextY / sideArray.length;
510
491
  const nGravityMultiplier = Math.pow(nRatio, 1.5);
511
492
  const nx = (isLeft ? baseX - nextWidth * nGravityMultiplier : baseX + nextWidth * nGravityMultiplier) + dx;
512
- targetCtx.lineTo(px, py);
513
- targetCtx.lineTo(nx, ny);
493
+ ctx.lineTo(px, py);
494
+ ctx.lineTo(nx, ny);
514
495
  }
515
- targetCtx.lineTo(baseX + dx, rect.bottom + dy);
516
- targetCtx.closePath();
517
- targetCtx.fill();
496
+ ctx.lineTo(baseX + dx, rect.bottom + dy);
497
+ ctx.closePath();
498
+ ctx.fill();
518
499
  };
519
500
  if (hasLeftSnow) drawSide(acc.leftSide, true);
520
501
  if (hasRightSnow) drawSide(acc.rightSide, false);
521
502
  }
522
503
  ctx.shadowBlur = 0;
523
- if (fixedCtx) fixedCtx.shadowBlur = 0;
524
504
  };
525
505
 
526
506
  // src/Snowfall.tsx
527
- import { Fragment, jsx as jsx2, jsxs } from "react/jsx-runtime";
507
+ import { Fragment, jsx as jsx2 } from "react/jsx-runtime";
528
508
  function Snowfall() {
529
- const { isEnabled, physicsConfig } = useSnowfall();
509
+ const { isEnabled, physicsConfig, setMetrics } = useSnowfall();
530
510
  const isEnabledRef = useRef(isEnabled);
531
511
  const physicsConfigRef = useRef(physicsConfig);
512
+ const setMetricsRef = useRef(setMetrics);
532
513
  const [isMounted, setIsMounted] = useState2(false);
533
514
  const [isVisible, setIsVisible] = useState2(false);
534
515
  const canvasRef = useRef(null);
535
- const fixedCanvasRef = useRef(null);
536
516
  const snowflakesRef = useRef([]);
537
517
  const accumulationRef = useRef(/* @__PURE__ */ new Map());
538
518
  const animationIdRef = useRef(0);
519
+ const fpsFrames = useRef([]);
520
+ const metricsRef = useRef({
521
+ scanTime: 0,
522
+ rectUpdateTime: 0,
523
+ frameTime: 0,
524
+ rafGap: 0,
525
+ clearTime: 0,
526
+ physicsTime: 0,
527
+ drawTime: 0
528
+ });
539
529
  useEffect(() => {
540
530
  setIsMounted(true);
541
531
  }, []);
@@ -545,40 +535,58 @@ function Snowfall() {
545
535
  useEffect(() => {
546
536
  physicsConfigRef.current = physicsConfig;
547
537
  }, [physicsConfig]);
538
+ useEffect(() => {
539
+ setMetricsRef.current = setMetrics;
540
+ }, [setMetrics]);
548
541
  useEffect(() => {
549
542
  if (!isMounted) return;
550
543
  const canvas = canvasRef.current;
551
- const fixedCanvas = fixedCanvasRef.current;
552
- if (!canvas || !fixedCanvas) return;
544
+ if (!canvas) return;
553
545
  const ctx = canvas.getContext("2d");
554
- const fixedCtx = fixedCanvas.getContext("2d");
555
- if (!ctx || !fixedCtx) return;
546
+ if (!ctx) return;
556
547
  const resizeCanvas = () => {
557
- if (canvasRef.current && fixedCanvasRef.current) {
558
- const newHeight = Math.max(document.documentElement.scrollHeight, window.innerHeight);
559
- const newWidth = Math.max(document.documentElement.scrollWidth, window.innerWidth);
560
- if (canvasRef.current.height !== newHeight || canvasRef.current.width !== newWidth) {
561
- canvasRef.current.width = newWidth;
562
- canvasRef.current.height = newHeight;
563
- }
564
- if (fixedCanvasRef.current.width !== window.innerWidth || fixedCanvasRef.current.height !== window.innerHeight) {
565
- fixedCanvasRef.current.width = window.innerWidth;
566
- fixedCanvasRef.current.height = window.innerHeight;
567
- }
548
+ if (canvasRef.current) {
549
+ const newWidth = window.innerWidth;
550
+ const newHeight = window.innerHeight;
551
+ const dpr = window.devicePixelRatio || 1;
552
+ canvasRef.current.width = newWidth * dpr;
553
+ canvasRef.current.height = newHeight * dpr;
554
+ canvasRef.current.style.width = `${newWidth}px`;
555
+ canvasRef.current.style.height = `${newHeight}px`;
568
556
  }
569
557
  };
570
558
  resizeCanvas();
571
- const resizeObserver = new ResizeObserver(() => {
559
+ const windowResizeObserver = new ResizeObserver(() => {
572
560
  resizeCanvas();
573
561
  });
574
- resizeObserver.observe(document.body);
562
+ windowResizeObserver.observe(document.body);
563
+ const surfaceObserver = new ResizeObserver((entries) => {
564
+ let needsUpdate = false;
565
+ for (const entry of entries) {
566
+ if (entry.target.isConnected) {
567
+ needsUpdate = true;
568
+ break;
569
+ }
570
+ }
571
+ if (needsUpdate) {
572
+ initAccumulationWrapper();
573
+ }
574
+ });
575
575
  snowflakesRef.current = [];
576
576
  const initAccumulationWrapper = () => {
577
+ const scanStart = performance.now();
577
578
  initializeAccumulation(accumulationRef.current, physicsConfigRef.current);
579
+ surfaceObserver.disconnect();
580
+ for (const [el] of accumulationRef.current) {
581
+ surfaceObserver.observe(el);
582
+ }
583
+ metricsRef.current.scanTime = performance.now() - scanStart;
578
584
  };
579
585
  initAccumulationWrapper();
580
586
  setIsVisible(true);
581
587
  let lastTime = 0;
588
+ let lastMetricsUpdate = 0;
589
+ let elementRects = [];
582
590
  const animate = (currentTime) => {
583
591
  if (lastTime === 0) {
584
592
  lastTime = currentTime;
@@ -586,23 +594,64 @@ function Snowfall() {
586
594
  return;
587
595
  }
588
596
  const deltaTime = Math.min(currentTime - lastTime, 50);
597
+ const now = performance.now();
598
+ fpsFrames.current.push(now);
599
+ fpsFrames.current = fpsFrames.current.filter((t) => now - t < 1e3);
600
+ metricsRef.current.rafGap = currentTime - lastTime;
589
601
  lastTime = currentTime;
590
602
  const dt = deltaTime / 16.67;
591
- ctx.clearRect(0, 0, canvas.width, canvas.height);
592
- fixedCtx.clearRect(0, 0, fixedCanvas.width, fixedCanvas.height);
603
+ const frameStartTime = performance.now();
604
+ const clearStart = performance.now();
605
+ const dpr = window.devicePixelRatio || 1;
606
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
607
+ ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
608
+ const scrollX = window.scrollX;
609
+ const scrollY = window.scrollY;
610
+ ctx.translate(-scrollX, -scrollY);
611
+ metricsRef.current.clearTime = performance.now() - clearStart;
593
612
  const snowflakes = snowflakesRef.current;
594
- const elementRects = getElementRects(accumulationRef.current);
613
+ const rectStart = performance.now();
614
+ elementRects = getElementRects(accumulationRef.current);
615
+ metricsRef.current.rectUpdateTime = performance.now() - rectStart;
616
+ const physicsStart = performance.now();
595
617
  meltAndSmoothAccumulation(elementRects, physicsConfigRef.current, dt);
596
- updateSnowflakes(snowflakes, elementRects, physicsConfigRef.current, dt, canvas.width, canvas.height);
618
+ updateSnowflakes(
619
+ snowflakes,
620
+ elementRects,
621
+ physicsConfigRef.current,
622
+ dt,
623
+ document.documentElement.scrollWidth,
624
+ document.documentElement.scrollHeight
625
+ );
626
+ metricsRef.current.physicsTime = performance.now() - physicsStart;
627
+ const drawStart = performance.now();
597
628
  for (const flake of snowflakes) {
598
629
  drawSnowflake(ctx, flake);
599
630
  }
600
631
  if (isEnabledRef.current && snowflakes.length < physicsConfigRef.current.MAX_FLAKES) {
601
632
  const isBackground = Math.random() < 0.4;
602
- snowflakes.push(createSnowflake(canvas.width, physicsConfigRef.current, isBackground));
633
+ snowflakes.push(createSnowflake(document.documentElement.scrollWidth, physicsConfigRef.current, isBackground));
634
+ }
635
+ drawAccumulations(ctx, elementRects);
636
+ drawSideAccumulations(ctx, elementRects);
637
+ metricsRef.current.drawTime = performance.now() - drawStart;
638
+ metricsRef.current.frameTime = performance.now() - frameStartTime;
639
+ if (currentTime - lastMetricsUpdate > 500) {
640
+ setMetricsRef.current({
641
+ fps: fpsFrames.current.length,
642
+ frameTime: metricsRef.current.frameTime,
643
+ scanTime: metricsRef.current.scanTime,
644
+ rectUpdateTime: metricsRef.current.rectUpdateTime,
645
+ surfaceCount: accumulationRef.current.size,
646
+ flakeCount: snowflakes.length,
647
+ maxFlakes: physicsConfigRef.current.MAX_FLAKES,
648
+ rafGap: metricsRef.current.rafGap,
649
+ clearTime: metricsRef.current.clearTime,
650
+ physicsTime: metricsRef.current.physicsTime,
651
+ drawTime: metricsRef.current.drawTime
652
+ });
653
+ lastMetricsUpdate = currentTime;
603
654
  }
604
- drawAccumulations(ctx, fixedCtx, elementRects);
605
- drawSideAccumulations(ctx, fixedCtx, elementRects);
606
655
  animationIdRef.current = requestAnimationFrame(animate);
607
656
  };
608
657
  animationIdRef.current = requestAnimationFrame(animate);
@@ -612,52 +661,266 @@ function Snowfall() {
612
661
  initAccumulationWrapper();
613
662
  };
614
663
  window.addEventListener("resize", handleResize);
615
- const checkInterval = setInterval(initAccumulationWrapper, 3e3);
664
+ const checkInterval = setInterval(initAccumulationWrapper, 5e3);
616
665
  return () => {
617
666
  cancelAnimationFrame(animationIdRef.current);
618
667
  window.removeEventListener("resize", handleResize);
619
668
  clearInterval(checkInterval);
620
- resizeObserver.disconnect();
669
+ windowResizeObserver.disconnect();
670
+ surfaceObserver.disconnect();
621
671
  };
622
672
  }, [isMounted]);
623
673
  if (!isMounted) return null;
624
- return /* @__PURE__ */ jsxs(Fragment, { children: [
625
- /* @__PURE__ */ jsx2(
626
- "canvas",
627
- {
628
- ref: canvasRef,
629
- style: {
630
- position: "absolute",
631
- top: 0,
632
- left: 0,
633
- pointerEvents: "none",
634
- zIndex: 9999,
635
- opacity: isVisible ? 1 : 0,
636
- transition: "opacity 0.3s ease-in"
637
- },
638
- "aria-hidden": "true"
639
- }
640
- ),
641
- /* @__PURE__ */ jsx2(
642
- "canvas",
643
- {
644
- ref: fixedCanvasRef,
645
- style: {
646
- position: "fixed",
647
- top: 0,
648
- left: 0,
649
- pointerEvents: "none",
650
- zIndex: 9999,
651
- opacity: isVisible ? 1 : 0,
652
- transition: "opacity 0.3s ease-in"
653
- },
654
- "aria-hidden": "true"
674
+ return /* @__PURE__ */ jsx2(Fragment, { children: /* @__PURE__ */ jsx2(
675
+ "canvas",
676
+ {
677
+ ref: canvasRef,
678
+ style: {
679
+ position: "fixed",
680
+ // FIXED position to eliminate scroll jitter
681
+ top: 0,
682
+ left: 0,
683
+ pointerEvents: "none",
684
+ zIndex: 9999,
685
+ opacity: isVisible ? 1 : 0,
686
+ transition: "opacity 0.3s ease-in",
687
+ willChange: "transform"
688
+ },
689
+ "aria-hidden": "true"
690
+ }
691
+ ) });
692
+ }
693
+
694
+ // src/DebugPanel.tsx
695
+ import { useEffect as useEffect2, useState as useState3 } from "react";
696
+ import { Fragment as Fragment2, jsx as jsx3, jsxs } from "react/jsx-runtime";
697
+ function DebugPanel({ defaultOpen = true }) {
698
+ const { debugMode, toggleDebug, metrics } = useSnowfall();
699
+ const [isMinimized, setIsMinimized] = useState3(!defaultOpen);
700
+ const [copied, setCopied] = useState3(false);
701
+ useEffect2(() => {
702
+ const handleKeyDown = (e) => {
703
+ if (e.shiftKey && e.key === "D") {
704
+ toggleDebug();
655
705
  }
656
- )
657
- ] });
706
+ };
707
+ window.addEventListener("keydown", handleKeyDown);
708
+ return () => window.removeEventListener("keydown", handleKeyDown);
709
+ }, [toggleDebug]);
710
+ const copyToClipboard = () => {
711
+ if (metrics) {
712
+ const data = {
713
+ ...metrics,
714
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
715
+ userAgent: navigator.userAgent,
716
+ canvasSize: {
717
+ width: window.innerWidth,
718
+ height: window.innerHeight
719
+ }
720
+ };
721
+ navigator.clipboard.writeText(JSON.stringify(data, null, 2));
722
+ setCopied(true);
723
+ setTimeout(() => setCopied(false), 2e3);
724
+ }
725
+ };
726
+ if (!debugMode) return null;
727
+ return /* @__PURE__ */ jsxs(
728
+ "div",
729
+ {
730
+ "data-snowfall": "top",
731
+ style: {
732
+ position: "fixed",
733
+ bottom: "80px",
734
+ left: "24px",
735
+ backgroundColor: "rgba(15, 23, 42, 0.75)",
736
+ backdropFilter: "blur(16px)",
737
+ WebkitBackdropFilter: "blur(16px)",
738
+ color: "#e2e8f0",
739
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
740
+ fontSize: "12px",
741
+ padding: isMinimized ? "12px" : "20px",
742
+ borderRadius: "16px",
743
+ zIndex: 1e4,
744
+ minWidth: isMinimized ? "auto" : "300px",
745
+ maxWidth: "100%",
746
+ border: "1px solid rgba(255, 255, 255, 0.1)",
747
+ boxShadow: "0 20px 25px -5px rgba(0, 0, 0, 0.2), 0 8px 10px -6px rgba(0, 0, 0, 0.2)",
748
+ transition: "all 0.2s ease"
749
+ },
750
+ children: [
751
+ /* @__PURE__ */ jsxs("div", { style: {
752
+ display: "flex",
753
+ justifyContent: "space-between",
754
+ alignItems: "center",
755
+ marginBottom: isMinimized ? 0 : "16px",
756
+ gap: "16px"
757
+ }, children: [
758
+ /* @__PURE__ */ jsxs("div", { style: { fontWeight: "600", color: "#fff", display: "flex", alignItems: "center", gap: "8px" }, children: [
759
+ /* @__PURE__ */ jsx3("span", { style: { fontSize: "14px" }, children: "\u2744\uFE0F" }),
760
+ /* @__PURE__ */ jsx3("span", { style: {
761
+ background: "linear-gradient(to right, #60a5fa, #22d3ee)",
762
+ WebkitBackgroundClip: "text",
763
+ WebkitTextFillColor: "transparent",
764
+ fontWeight: "700"
765
+ }, children: "DEBUG" })
766
+ ] }),
767
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: "8px" }, children: [
768
+ /* @__PURE__ */ jsx3(
769
+ "button",
770
+ {
771
+ onClick: () => setIsMinimized(!isMinimized),
772
+ style: {
773
+ background: "rgba(255, 255, 255, 0.1)",
774
+ border: "1px solid rgba(255, 255, 255, 0.1)",
775
+ color: "#fff",
776
+ cursor: "pointer",
777
+ width: "24px",
778
+ height: "24px",
779
+ display: "flex",
780
+ alignItems: "center",
781
+ justifyContent: "center",
782
+ borderRadius: "6px",
783
+ fontSize: "10px"
784
+ },
785
+ children: isMinimized ? "\u25B2" : "\u25BC"
786
+ }
787
+ ),
788
+ /* @__PURE__ */ jsx3(
789
+ "button",
790
+ {
791
+ onClick: toggleDebug,
792
+ style: {
793
+ background: "rgba(239, 68, 68, 0.15)",
794
+ border: "1px solid rgba(239, 68, 68, 0.2)",
795
+ color: "#f87171",
796
+ cursor: "pointer",
797
+ width: "24px",
798
+ height: "24px",
799
+ display: "flex",
800
+ alignItems: "center",
801
+ justifyContent: "center",
802
+ borderRadius: "6px",
803
+ fontSize: "12px"
804
+ },
805
+ children: "\u2715"
806
+ }
807
+ )
808
+ ] })
809
+ ] }),
810
+ !isMinimized && metrics && /* @__PURE__ */ jsxs(Fragment2, { children: [
811
+ /* @__PURE__ */ jsxs("div", { style: { marginBottom: "12px", paddingBottom: "12px", borderBottom: "1px solid rgba(255,255,255,0.08)" }, children: [
812
+ /* @__PURE__ */ jsx3("div", { style: { color: "#94a3b8", marginBottom: "8px", fontSize: "11px", fontWeight: "600", letterSpacing: "0.5px", textTransform: "uppercase" }, children: "Performance" }),
813
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", justifyContent: "space-between", marginBottom: "4px" }, children: [
814
+ /* @__PURE__ */ jsx3("span", { children: "FPS" }),
815
+ /* @__PURE__ */ jsx3("span", { style: { fontWeight: "bold", color: metrics.fps < 30 ? "#f87171" : metrics.fps < 50 ? "#facc15" : "#4ade80" }, children: metrics.fps.toFixed(1) })
816
+ ] }),
817
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", justifyContent: "space-between", marginBottom: "4px" }, children: [
818
+ /* @__PURE__ */ jsx3("span", { children: "Frame Time" }),
819
+ /* @__PURE__ */ jsxs("span", { style: { fontFamily: "monospace" }, children: [
820
+ metrics.frameTime.toFixed(2),
821
+ "ms"
822
+ ] })
823
+ ] }),
824
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", justifyContent: "space-between" }, children: [
825
+ /* @__PURE__ */ jsx3("span", { style: { color: metrics.rafGap && metrics.rafGap > 20 ? "#fbbf24" : "inherit" }, children: "rAF Gap" }),
826
+ /* @__PURE__ */ jsxs("span", { style: { fontFamily: "monospace" }, children: [
827
+ metrics.rafGap?.toFixed(1) || 0,
828
+ "ms"
829
+ ] })
830
+ ] })
831
+ ] }),
832
+ /* @__PURE__ */ jsxs("div", { style: { marginBottom: "12px", paddingBottom: "12px", borderBottom: "1px solid rgba(255,255,255,0.08)" }, children: [
833
+ /* @__PURE__ */ jsx3("div", { style: { color: "#94a3b8", marginBottom: "8px", fontSize: "11px", fontWeight: "600", letterSpacing: "0.5px", textTransform: "uppercase" }, children: "Detailed Timings" }),
834
+ /* @__PURE__ */ jsxs("div", { style: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: "4px 12px" }, children: [
835
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", justifyContent: "space-between" }, children: [
836
+ /* @__PURE__ */ jsx3("span", { children: "Clear" }),
837
+ " ",
838
+ /* @__PURE__ */ jsxs("span", { style: { fontFamily: "monospace" }, children: [
839
+ metrics.clearTime?.toFixed(2) || 0,
840
+ "ms"
841
+ ] })
842
+ ] }),
843
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", justifyContent: "space-between" }, children: [
844
+ /* @__PURE__ */ jsx3("span", { children: "Physics" }),
845
+ " ",
846
+ /* @__PURE__ */ jsxs("span", { style: { fontFamily: "monospace" }, children: [
847
+ metrics.physicsTime?.toFixed(2) || 0,
848
+ "ms"
849
+ ] })
850
+ ] }),
851
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", justifyContent: "space-between" }, children: [
852
+ /* @__PURE__ */ jsx3("span", { children: "Draw" }),
853
+ " ",
854
+ /* @__PURE__ */ jsxs("span", { style: { fontFamily: "monospace" }, children: [
855
+ metrics.drawTime?.toFixed(2) || 0,
856
+ "ms"
857
+ ] })
858
+ ] }),
859
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", justifyContent: "space-between" }, children: [
860
+ /* @__PURE__ */ jsx3("span", { children: "Scan" }),
861
+ " ",
862
+ /* @__PURE__ */ jsxs("span", { style: { fontFamily: "monospace" }, children: [
863
+ metrics.scanTime.toFixed(2),
864
+ "ms"
865
+ ] })
866
+ ] }),
867
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", justifyContent: "space-between", gridColumn: "span 2" }, children: [
868
+ /* @__PURE__ */ jsx3("span", { children: "Rect Update" }),
869
+ " ",
870
+ /* @__PURE__ */ jsxs("span", { style: { fontFamily: "monospace" }, children: [
871
+ metrics.rectUpdateTime.toFixed(2),
872
+ "ms"
873
+ ] })
874
+ ] })
875
+ ] })
876
+ ] }),
877
+ /* @__PURE__ */ jsxs("div", { style: { marginBottom: "16px" }, children: [
878
+ /* @__PURE__ */ jsx3("div", { style: { color: "#94a3b8", marginBottom: "8px", fontSize: "11px", fontWeight: "600", letterSpacing: "0.5px", textTransform: "uppercase" }, children: "Counts" }),
879
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", justifyContent: "space-between", marginBottom: "4px" }, children: [
880
+ /* @__PURE__ */ jsx3("span", { children: "Snowflakes" }),
881
+ /* @__PURE__ */ jsxs("span", { style: { fontFamily: "monospace" }, children: [
882
+ metrics.flakeCount,
883
+ " / ",
884
+ metrics.maxFlakes
885
+ ] })
886
+ ] }),
887
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", justifyContent: "space-between" }, children: [
888
+ /* @__PURE__ */ jsx3("span", { children: "Surfaces" }),
889
+ /* @__PURE__ */ jsx3("span", { style: { fontFamily: "monospace" }, children: metrics.surfaceCount })
890
+ ] })
891
+ ] }),
892
+ /* @__PURE__ */ jsx3(
893
+ "button",
894
+ {
895
+ onClick: copyToClipboard,
896
+ style: {
897
+ width: "100%",
898
+ padding: "10px",
899
+ background: copied ? "rgba(34, 197, 94, 0.2)" : "rgba(255, 255, 255, 0.05)",
900
+ border: copied ? "1px solid rgba(34, 197, 94, 0.5)" : "1px solid rgba(255, 255, 255, 0.1)",
901
+ color: copied ? "#4ade80" : "#fff",
902
+ cursor: "pointer",
903
+ borderRadius: "8px",
904
+ fontSize: "11px",
905
+ fontWeight: "600",
906
+ transition: "all 0.2s",
907
+ display: "flex",
908
+ alignItems: "center",
909
+ justifyContent: "center",
910
+ gap: "6px"
911
+ },
912
+ children: copied ? "\u2713 COPIED" : "\u{1F4CB} COPY METRICS"
913
+ }
914
+ ),
915
+ /* @__PURE__ */ jsx3("div", { style: { marginTop: "12px", fontSize: "10px", color: "#64748b", textAlign: "center" }, children: "Shift+D to toggle" })
916
+ ] })
917
+ ]
918
+ }
919
+ );
658
920
  }
659
921
  export {
660
922
  DEFAULT_PHYSICS,
923
+ DebugPanel,
661
924
  Snowfall,
662
925
  SnowfallProvider,
663
926
  useSnowfall