snowstack 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Carl Kendrick Camus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,169 @@
1
+ # ❄️ SnowStack
2
+
3
+ **SnowStack** is a lightweight, interactive snow effect for React/Next.js that combines:
4
+
5
+ - Smooth snowfall (React-Snowfall–style calm mode)
6
+ - Snow accumulation at the bottom of the screen
7
+ - Optional **interactive shovel** (scoop + throw with physics)
8
+ - Mobile-safe interaction (no scroll hijacking)
9
+ - Performance-bounded presets to protect production sites
10
+
11
+ Built with **Canvas + Matter.js**, designed to be fun without being destructive.
12
+
13
+ ---
14
+
15
+ ## ✨ Features
16
+
17
+ - 🌨 **Snowfall Engine** β€” density, speed, size, wind
18
+ - ❄️ **Accumulation** β€” piles up naturally at the bottom
19
+ - πŸ₯„ **Interactive Shovel (optional)** β€” scoop and throw chunks with physics
20
+ - πŸ“± **Mobile-safe controls** β€” shovel ON/OFF toggle + pointer capture
21
+ - 🧊 **Presets-first** β€” safe defaults, bounded ranges
22
+ - ⚑ **Performance guarded** β€” capped FPS and chunk limits
23
+
24
+ ---
25
+
26
+ ## πŸ“¦ Install
27
+
28
+ ```bash
29
+ npm install snowstack
30
+ # or
31
+ pnpm add snowstack
32
+ # or
33
+ yarn add snowstack
34
+ ````
35
+
36
+ ---
37
+
38
+ ## πŸš€ Quick Start
39
+
40
+ > ⚠️ SnowStack is a client-side effect and must be used inside `"use client"` components.
41
+
42
+
43
+ ```tsx
44
+ import { SnowStack } from "snowstack";
45
+
46
+ export default function Page() {
47
+ return <SnowStack />;
48
+ }
49
+ ```
50
+
51
+ Default behavior is **calm, non-interactive snowfall**.
52
+
53
+ ---
54
+
55
+ ## πŸŽ› Presets
56
+
57
+ Available presets:
58
+
59
+ * `calm` (default) β€” subtle snowfall
60
+ * `cozy` β€” a bit denser
61
+ * `storm` β€” heavy snow + stronger wind (still clamped for safety)
62
+ * `interactive` β€” enables shovel + physics chunks
63
+
64
+ ```tsx
65
+ <SnowStack preset="cozy" />
66
+ ```
67
+
68
+ ---
69
+
70
+ ## πŸ₯„ Interactive Mode (Shovel)
71
+
72
+ ```tsx
73
+ <SnowStack preset="interactive" interactive />
74
+ ```
75
+
76
+ ### Desktop
77
+
78
+ * Hold **Shift**
79
+ * Drag near the snow pile to scoop
80
+ * Release to throw
81
+
82
+ ### Mobile
83
+
84
+ * Tap **Shovel: ON**
85
+ * Drag to scoop
86
+ * Release to throw
87
+ * Tap **Shovel: OFF** to disable
88
+
89
+ ---
90
+
91
+ ## Safe Customization (Limited by Design)
92
+
93
+ SnowStack exposes only safe knobs:
94
+
95
+ ```tsx
96
+ <SnowStack
97
+ preset="calm"
98
+ intensity={0.4} // 0..1 (density)
99
+ speed={0.3} // 0..1 (fall speed)
100
+ wind={0.2} // 0..1 (wind influence)
101
+ interactive={false}
102
+ />
103
+ ```
104
+
105
+ All internal values are **clamped** to prevent runaway CPU usage.
106
+
107
+ ---
108
+
109
+ ## πŸ“· Demo
110
+
111
+ ![SnowStack Demo](./assets/demo.gif)
112
+
113
+ - This demo used a preset "storm"
114
+ ---
115
+
116
+ ## 🧠 Tech
117
+
118
+ * React / Next.js (`"use client"`)
119
+ * Canvas rendering
120
+ * Matter.js (snow chunks only)
121
+ * requestAnimationFrame loops
122
+ * Minimal DOM (mostly canvas)
123
+
124
+ ---
125
+
126
+ ## ⚑ Performance Notes
127
+
128
+ SnowStack automatically:
129
+
130
+ * caps snowflake counts
131
+ * limits physics bodies
132
+ * removes off-screen chunks
133
+ * keeps FPS bounded
134
+
135
+ ---
136
+
137
+ ## 🀝 Contributing & Forking
138
+
139
+ SnowStack is intentionally designed to be **safe by default**.
140
+
141
+ You are welcome to:
142
+
143
+ - Fork the project
144
+ - Modify visuals (SVGs, colors, shovel appearance)
145
+ - Create new presets
146
+ - Optimize or extend physics behavior
147
+
148
+ ### Design Philosophy
149
+
150
+ To keep SnowStack production-friendly:
151
+
152
+ - Public props are **intentionally limited**
153
+ - All internal values are **clamped**
154
+ - Presets are preferred over raw configuration
155
+ - Heavy physics runs **only when interactive mode is enabled**
156
+
157
+ If you add new controls, please:
158
+ - keep reasonable bounds
159
+ - avoid unbounded loops or unguarded physics
160
+ - respect mobile performance constraints
161
+
162
+ Pull requests are welcome if they align with these goals.
163
+
164
+ ---
165
+
166
+
167
+
168
+ ## πŸ“œ License
169
+ MIT
@@ -0,0 +1,33 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+
3
+ type SnowPresetName = "calm" | "cozy" | "storm" | "interactive";
4
+ type SnowStackProps = {
5
+ enabled?: boolean;
6
+ preset?: SnowPresetName;
7
+ intensity?: number;
8
+ speed?: number;
9
+ wind?: number;
10
+ interactive?: boolean;
11
+ };
12
+ declare function SnowStack(props: SnowStackProps): react_jsx_runtime.JSX.Element;
13
+
14
+ type MatterConfig = {
15
+ gravityY: number;
16
+ restitution: number;
17
+ friction: number;
18
+ frictionAir: number;
19
+ ttlMs: number;
20
+ allowSideExit: boolean;
21
+ maxBodies: number;
22
+ };
23
+ type MatterAPI = {
24
+ throwChunks: (x: number, y: number, vx: number, vy: number, count: number, rBase: number) => void;
25
+ setConfig: (partial: Partial<MatterConfig>) => void;
26
+ };
27
+ declare function MatterSnowChunks({ zIndex, apiRef, initialConfig, }: {
28
+ zIndex?: number;
29
+ apiRef: React.MutableRefObject<MatterAPI | null>;
30
+ initialConfig?: Partial<MatterConfig>;
31
+ }): react_jsx_runtime.JSX.Element;
32
+
33
+ export { type MatterAPI, type MatterConfig, MatterSnowChunks, SnowStack, type SnowStackProps };
package/dist/index.js ADDED
@@ -0,0 +1,907 @@
1
+ import { useRef, useEffect, useMemo, useState } from 'react';
2
+ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
3
+ import { Shovel } from 'lucide-react';
4
+ import Matter from 'matter-js';
5
+
6
+ function useRafLoop(fn) {
7
+ const fnRef = useRef(fn);
8
+ fnRef.current = fn;
9
+ useEffect(() => {
10
+ let raf = 0;
11
+ const loop = (t) => {
12
+ raf = requestAnimationFrame(loop);
13
+ fnRef.current(t);
14
+ };
15
+ raf = requestAnimationFrame(loop);
16
+ return () => cancelAnimationFrame(raf);
17
+ }, []);
18
+ }
19
+
20
+ // src/physics/physics.ts
21
+ function clamp(n, a, b) {
22
+ return Math.max(a, Math.min(b, n));
23
+ }
24
+
25
+ // src/physics/snowHeightfield.ts
26
+ function createHeightfield(cols, colW, w, h, maxDepth) {
27
+ return { cols, colW, w, h, maxDepth, heights: new Float32Array(cols) };
28
+ }
29
+ function depositAtX(model, x, amount) {
30
+ const i = clamp(Math.floor(x / model.colW), 0, model.cols - 1);
31
+ add(model, i, amount * 0.6);
32
+ add(model, i - 1, amount * 0.22);
33
+ add(model, i + 1, amount * 0.22);
34
+ }
35
+ function add(model, idx, amt) {
36
+ if (idx < 0 || idx >= model.cols) return;
37
+ model.heights[idx] = clamp(model.heights[idx] + amt, 0, model.maxDepth);
38
+ }
39
+ function smoothHeights(model, k) {
40
+ const h = model.heights;
41
+ const tmp = new Float32Array(h.length);
42
+ for (let i = 0; i < h.length; i++) {
43
+ const a = h[i - 1] ?? h[i];
44
+ const b = h[i];
45
+ const c = h[i + 1] ?? h[i];
46
+ const avg = (a + b + c) / 3;
47
+ tmp[i] = b + (avg - b) * k;
48
+ }
49
+ model.heights.set(tmp);
50
+ }
51
+ function avgHeight(model) {
52
+ const h = model.heights;
53
+ let sum = 0;
54
+ for (let i = 0; i < h.length; i++) sum += h[i];
55
+ return sum / h.length;
56
+ }
57
+ function driftGround(model, windX) {
58
+ const h = model.heights;
59
+ const tmp = new Float32Array(h.length);
60
+ tmp.set(h);
61
+ const k = Math.min(0.06, Math.abs(windX) * 0.02);
62
+ if (k <= 0) return;
63
+ if (windX > 0) {
64
+ for (let i = h.length - 2; i >= 0; i--) {
65
+ const move = tmp[i] * k;
66
+ tmp[i] -= move;
67
+ tmp[i + 1] += move;
68
+ }
69
+ } else {
70
+ for (let i = 1; i < h.length; i++) {
71
+ const move = tmp[i] * k;
72
+ tmp[i] -= move;
73
+ tmp[i - 1] += move;
74
+ }
75
+ }
76
+ for (let i = 0; i < tmp.length; i++) tmp[i] = clamp(tmp[i], 0, model.maxDepth);
77
+ model.heights.set(tmp);
78
+ }
79
+ function removeSnowRect(model, x, y, w, h, maxRemove) {
80
+ const leftCol = clamp(Math.floor(x / model.colW), 0, model.cols - 1);
81
+ const rightCol = clamp(Math.floor((x + w) / model.colW), 0, model.cols - 1);
82
+ let removed = 0;
83
+ for (let i = leftCol; i <= rightCol; i++) {
84
+ if (removed >= maxRemove) break;
85
+ const surfaceY = model.h - model.heights[i];
86
+ const rectBottom = y + h;
87
+ if (rectBottom <= surfaceY) continue;
88
+ const penetration = rectBottom - surfaceY;
89
+ const take = clamp(penetration * 0.35, 0, model.heights[i]);
90
+ const remaining = maxRemove - removed;
91
+ const actual = Math.min(take, remaining);
92
+ model.heights[i] = clamp(model.heights[i] - actual, 0, model.maxDepth);
93
+ removed += actual;
94
+ }
95
+ return removed;
96
+ }
97
+ function SnowAccumulationCanvas({
98
+ zIndex = 2,
99
+ enabled,
100
+ windX,
101
+ apiRef,
102
+ onAvgDepth,
103
+ // package controls
104
+ pileMaxDepth = 220,
105
+ pileSmoothness = 0.06,
106
+ pileFPS = 30,
107
+ colW = 6
108
+ }) {
109
+ const canvasRef = useRef(null);
110
+ const modelRef = useRef(null);
111
+ const drawRef = useRef({
112
+ dpr: 1,
113
+ w: 0,
114
+ h: 0
115
+ });
116
+ const settings = useMemo(
117
+ () => ({
118
+ colW,
119
+ maxDepth: pileMaxDepth,
120
+ smoothK: pileSmoothness,
121
+ fps: pileFPS
122
+ }),
123
+ [colW, pileMaxDepth, pileSmoothness, pileFPS]
124
+ );
125
+ useEffect(() => {
126
+ const canvas = canvasRef.current;
127
+ const resize = () => {
128
+ const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
129
+ const w = window.innerWidth;
130
+ const h = window.innerHeight;
131
+ canvas.style.width = w + "px";
132
+ canvas.style.height = h + "px";
133
+ canvas.width = Math.floor(w * dpr);
134
+ canvas.height = Math.floor(h * dpr);
135
+ drawRef.current = { dpr, w, h };
136
+ const cols = Math.ceil(w / settings.colW);
137
+ modelRef.current = createHeightfield(cols, settings.colW, w, h, settings.maxDepth);
138
+ apiRef.current = {
139
+ getHeights: () => modelRef.current.heights,
140
+ getMeta: () => ({
141
+ cols: modelRef.current.cols,
142
+ colW: modelRef.current.colW,
143
+ w: modelRef.current.w,
144
+ h: modelRef.current.h
145
+ }),
146
+ removeRect: (x, y, rw, rh, maxRemove) => removeSnowRect(modelRef.current, x, y, rw, rh, maxRemove),
147
+ depositAtX: (x, amount) => {
148
+ const m = modelRef.current;
149
+ if (!m) return;
150
+ depositAtX(m, x, amount);
151
+ }
152
+ };
153
+ };
154
+ resize();
155
+ window.addEventListener("resize", resize);
156
+ return () => window.removeEventListener("resize", resize);
157
+ }, [apiRef, settings]);
158
+ const lastFrameRef = useRef(0);
159
+ useRafLoop((t) => {
160
+ if (!enabled) return;
161
+ const model = modelRef.current;
162
+ const canvas = canvasRef.current;
163
+ if (!model || !canvas) return;
164
+ const targetMs = 1e3 / settings.fps;
165
+ if (t - lastFrameRef.current < targetMs) return;
166
+ lastFrameRef.current = t;
167
+ driftGround(model, windX);
168
+ smoothHeights(model, settings.smoothK);
169
+ onAvgDepth?.(avgHeight(model));
170
+ drawSnow(canvas, model, drawRef.current.dpr);
171
+ });
172
+ if (!enabled) return null;
173
+ return /* @__PURE__ */ jsx(
174
+ "canvas",
175
+ {
176
+ ref: canvasRef,
177
+ "aria-hidden": "true",
178
+ style: { position: "fixed", inset: 0, zIndex, pointerEvents: "none" }
179
+ }
180
+ );
181
+ }
182
+ function drawSnow(canvas, model, dpr) {
183
+ const ctx = canvas.getContext("2d");
184
+ const w = model.w;
185
+ const h = model.h;
186
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
187
+ ctx.clearRect(0, 0, w, h);
188
+ const heights = model.heights;
189
+ const colW = model.colW;
190
+ ctx.beginPath();
191
+ ctx.moveTo(0, h);
192
+ for (let i = 0; i < heights.length; i++) {
193
+ const x = i * colW;
194
+ const y = h - heights[i];
195
+ ctx.lineTo(x, y);
196
+ }
197
+ ctx.lineTo(w, h);
198
+ ctx.closePath();
199
+ ctx.fillStyle = "rgba(255,255,255,0.92)";
200
+ ctx.fill();
201
+ ctx.strokeStyle = "rgba(255,255,255,0.65)";
202
+ ctx.lineWidth = 1;
203
+ ctx.stroke();
204
+ }
205
+ function SnowfallEngine({
206
+ zIndex = 1,
207
+ enabled,
208
+ getAPI,
209
+ windX,
210
+ flakeCount = 220,
211
+ snowfallSpeed = 1,
212
+ snowflakeSize = [0.9, 2.2]
213
+ }) {
214
+ const canvasRef = useRef(null);
215
+ const flakesRef = useRef([]);
216
+ const lastRef = useRef(0);
217
+ useEffect(() => {
218
+ if (!enabled) return;
219
+ const init = () => {
220
+ const w = window.innerWidth;
221
+ const h = window.innerHeight;
222
+ const [rMin, rMax] = snowflakeSize;
223
+ flakesRef.current = Array.from({ length: flakeCount }, () => ({
224
+ x: Math.random() * w,
225
+ y: Math.random() * h,
226
+ vx: (Math.random() - 0.5) * 0.25,
227
+ vy: (0.35 + Math.random() * 0.9) * snowfallSpeed,
228
+ r: rMin + Math.random() * (rMax - rMin)
229
+ }));
230
+ };
231
+ init();
232
+ window.addEventListener("resize", init);
233
+ return () => window.removeEventListener("resize", init);
234
+ }, [enabled, flakeCount, snowfallSpeed, snowflakeSize]);
235
+ useEffect(() => {
236
+ if (!enabled) return;
237
+ let raf = 0;
238
+ const loop = (t) => {
239
+ raf = requestAnimationFrame(loop);
240
+ const canvas = canvasRef.current;
241
+ const api = getAPI();
242
+ if (!canvas || !api) return;
243
+ const w = window.innerWidth;
244
+ const h = window.innerHeight;
245
+ const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
246
+ canvas.style.width = w + "px";
247
+ canvas.style.height = h + "px";
248
+ canvas.width = Math.floor(w * dpr);
249
+ canvas.height = Math.floor(h * dpr);
250
+ const ctx = canvas.getContext("2d");
251
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
252
+ ctx.clearRect(0, 0, w, h);
253
+ const dt = Math.min(40, Math.max(10, t - (lastRef.current || t)));
254
+ lastRef.current = t;
255
+ const flakes = flakesRef.current;
256
+ const meta = api.getMeta();
257
+ const heights = api.getHeights();
258
+ ctx.fillStyle = "rgba(255,255,255,0.92)";
259
+ for (const f of flakes) {
260
+ f.vx += windX * 26e-4;
261
+ f.vx *= 0.995;
262
+ f.vy += 11e-4 * (dt / 16.67) * snowfallSpeed;
263
+ f.x += f.vx * dt;
264
+ f.y += f.vy * dt;
265
+ if (f.x < -10) f.x = w + 10;
266
+ if (f.x > w + 10) f.x = -10;
267
+ const col = Math.max(0, Math.min(meta.cols - 1, Math.floor(f.x / meta.colW)));
268
+ const surfaceY = meta.h - heights[col];
269
+ if (f.y >= surfaceY) {
270
+ api.depositAtX(f.x, 0.55 + f.r * 0.28);
271
+ const [rMin, rMax] = snowflakeSize;
272
+ f.x = Math.random() * w;
273
+ f.y = -10 - Math.random() * 140;
274
+ f.vx = (Math.random() - 0.5) * 0.25;
275
+ f.vy = (0.35 + Math.random() * 0.95) * snowfallSpeed;
276
+ f.r = rMin + Math.random() * (rMax - rMin);
277
+ }
278
+ ctx.beginPath();
279
+ ctx.arc(f.x, f.y, f.r, 0, Math.PI * 2);
280
+ ctx.fill();
281
+ }
282
+ };
283
+ raf = requestAnimationFrame(loop);
284
+ return () => cancelAnimationFrame(raf);
285
+ }, [enabled, getAPI, windX, snowfallSpeed, snowflakeSize]);
286
+ if (!enabled) return null;
287
+ return /* @__PURE__ */ jsx(
288
+ "canvas",
289
+ {
290
+ ref: canvasRef,
291
+ "aria-hidden": "true",
292
+ style: { position: "fixed", inset: 0, zIndex, pointerEvents: "none" }
293
+ }
294
+ );
295
+ }
296
+ var clamp01 = (v) => Math.max(0, Math.min(1, v));
297
+ function ShovelWithSnow({
298
+ loadRatio,
299
+ size = 46,
300
+ strokeWidth = 2.2
301
+ }) {
302
+ const r = clamp01(loadRatio);
303
+ const snowOpacity = 0.25 + 0.75 * r;
304
+ const snowScale = 0.65 + 0.75 * r;
305
+ const snowX = 10.5;
306
+ const snowY = 13.2;
307
+ return /* @__PURE__ */ jsxs("div", { style: { position: "relative", width: size, height: size }, children: [
308
+ /* @__PURE__ */ jsx(Shovel, { size, strokeWidth }),
309
+ r > 0.03 && /* @__PURE__ */ jsx(
310
+ "svg",
311
+ {
312
+ width: size,
313
+ height: size,
314
+ viewBox: "0 0 24 24",
315
+ style: {
316
+ position: "absolute",
317
+ inset: 0,
318
+ pointerEvents: "none",
319
+ opacity: 1,
320
+ filter: "drop-shadow(0 4px 8px rgba(0,0,0,0.22))"
321
+ },
322
+ children: /* @__PURE__ */ jsxs("g", { transform: `translate(${snowX}, ${snowY}) scale(${snowScale})`, children: [
323
+ /* @__PURE__ */ jsx(
324
+ "path",
325
+ {
326
+ d: "M0.4 5.8\n C1.6 3.2 5.6 2.8 7.6 4.1\n C9.9 5.5 10.1 8.1 8.3 9.6\n C6.2 11.4 3.0 11.0 1.3 9.7\n C-0.4 8.4 -0.3 7.1 0.4 5.8 Z",
327
+ fill: `rgba(245,248,255,${snowOpacity})`
328
+ }
329
+ ),
330
+ /* @__PURE__ */ jsx(
331
+ "path",
332
+ {
333
+ d: "M1.6 5.8 C2.8 4.6 5.2 4.3 6.8 5.1",
334
+ stroke: `rgba(255,255,255,${0.35 + 0.5 * r})`,
335
+ strokeWidth: "0.7",
336
+ strokeLinecap: "round",
337
+ fill: "none"
338
+ }
339
+ ),
340
+ r > 0.2 && /* @__PURE__ */ jsxs(Fragment, { children: [
341
+ /* @__PURE__ */ jsx("circle", { cx: "7.5", cy: "8.2", r: 0.35 + 0.25 * r, fill: "rgba(255,255,255,0.95)" }),
342
+ /* @__PURE__ */ jsx("circle", { cx: "5.8", cy: "7.1", r: 0.28 + 0.18 * r, fill: "rgba(255,255,255,0.9)" }),
343
+ /* @__PURE__ */ jsx("circle", { cx: "6.4", cy: "6.3", r: 0.22 + 0.14 * r, fill: "rgba(255,255,255,0.85)" })
344
+ ] })
345
+ ] })
346
+ }
347
+ )
348
+ ] });
349
+ }
350
+ function isTouchDevice() {
351
+ if (typeof window === "undefined") return false;
352
+ return "ontouchstart" in window || navigator.maxTouchPoints > 0;
353
+ }
354
+ function SnowShovel({
355
+ zIndex = 3,
356
+ visible,
357
+ scale,
358
+ getAPI,
359
+ getMatter
360
+ }) {
361
+ const touch = useMemo(() => isTouchDevice(), []);
362
+ const [armed, setArmed] = useState(false);
363
+ const [pos, setPos] = useState({ x: 120, y: 120 });
364
+ const [loadUI, setLoadUI] = useState(0);
365
+ const loadRef = useRef(0);
366
+ const draggingRef = useRef(false);
367
+ const lastRef = useRef({ x: 0, y: 0, t: 0 });
368
+ const lastCommitRef = useRef(0);
369
+ const shovel = useMemo(() => {
370
+ const headW = 92 * scale;
371
+ const headH = 44 * scale;
372
+ const capacity = 140 * scale;
373
+ return { headW, headH, capacity };
374
+ }, [scale]);
375
+ useEffect(() => {
376
+ if (touch) return;
377
+ const kd = (e) => e.key === "Shift" && setArmed(true);
378
+ const ku = (e) => e.key === "Shift" && setArmed(false);
379
+ window.addEventListener("keydown", kd);
380
+ window.addEventListener("keyup", ku);
381
+ return () => {
382
+ window.removeEventListener("keydown", kd);
383
+ window.removeEventListener("keyup", ku);
384
+ };
385
+ }, [touch]);
386
+ useEffect(() => {
387
+ let raf = 0;
388
+ const tick = (t) => {
389
+ raf = requestAnimationFrame(tick);
390
+ if (!visible || !armed || !draggingRef.current) return;
391
+ const api = getAPI();
392
+ if (!api) return;
393
+ const load = loadRef.current;
394
+ const remaining = shovel.capacity - load;
395
+ if (remaining <= 0) return;
396
+ const bladeX = pos.x + 22 * scale;
397
+ const bladeY = pos.y + 16 * scale;
398
+ const rectX = bladeX - shovel.headW / 2;
399
+ const rectY = bladeY - shovel.headH / 2;
400
+ const taken = api.removeRect(rectX, rectY, shovel.headW, shovel.headH, remaining);
401
+ if (taken > 0) {
402
+ loadRef.current = clamp(load + taken, 0, shovel.capacity);
403
+ if (t - lastCommitRef.current > 50) {
404
+ lastCommitRef.current = t;
405
+ setLoadUI(loadRef.current);
406
+ }
407
+ }
408
+ };
409
+ raf = requestAnimationFrame(tick);
410
+ return () => cancelAnimationFrame(raf);
411
+ }, [armed, visible, pos.x, pos.y, scale, shovel.capacity, shovel.headW, shovel.headH, getAPI]);
412
+ function throwSnow(x, y) {
413
+ const matter = getMatter();
414
+ if (!matter) return;
415
+ const now = performance.now();
416
+ const last = lastRef.current;
417
+ const dt = Math.max(16, now - last.t);
418
+ const dx = x - last.x;
419
+ const dy = y - last.y;
420
+ let vx = dx / (dt / 16.67);
421
+ let vy = dy / (dt / 16.67);
422
+ vx = clamp(vx, -70, 70);
423
+ vy = clamp(vy, -70, 70);
424
+ lastRef.current = { x, y, t: now };
425
+ const amt = loadRef.current;
426
+ loadRef.current = 0;
427
+ setLoadUI(0);
428
+ const count = Math.floor(clamp(amt / (7 * scale), 10, 44));
429
+ const rBase = 2.4 * scale;
430
+ matter.throwChunks(x, y, vx, vy, count, rBase);
431
+ }
432
+ if (!visible) return null;
433
+ const loadRatio = loadUI / shovel.capacity;
434
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
435
+ touch && /* @__PURE__ */ jsx(
436
+ "button",
437
+ {
438
+ onClick: () => setArmed((v) => !v),
439
+ style: {
440
+ position: "fixed",
441
+ right: 16,
442
+ bottom: 16,
443
+ zIndex: zIndex + 50,
444
+ padding: "10px 14px",
445
+ borderRadius: 999,
446
+ border: "1px solid rgba(255,255,255,0.22)",
447
+ background: armed ? "rgba(255,255,255,0.92)" : "rgba(15, 23, 42, 0.60)",
448
+ color: armed ? "rgba(15,23,42,0.95)" : "rgba(255,255,255,0.92)",
449
+ fontSize: 12,
450
+ fontWeight: 700,
451
+ cursor: "pointer"
452
+ },
453
+ children: armed ? "Shovel: ON" : "Shovel: OFF"
454
+ }
455
+ ),
456
+ touch && armed && /* @__PURE__ */ jsx(
457
+ "div",
458
+ {
459
+ style: {
460
+ position: "fixed",
461
+ inset: 0,
462
+ zIndex: zIndex + 40,
463
+ background: "transparent",
464
+ touchAction: "none",
465
+ // βœ… stop scroll/zoom stealing drag
466
+ pointerEvents: "auto"
467
+ },
468
+ onPointerDown: (e) => {
469
+ const el = e.currentTarget;
470
+ el.setPointerCapture(e.pointerId);
471
+ draggingRef.current = true;
472
+ setPos({ x: e.clientX, y: e.clientY });
473
+ lastRef.current = { x: e.clientX, y: e.clientY, t: performance.now() };
474
+ },
475
+ onPointerMove: (e) => {
476
+ setPos({ x: e.clientX, y: e.clientY });
477
+ },
478
+ onPointerUp: (e) => {
479
+ if (loadRef.current > 1) throwSnow(e.clientX, e.clientY);
480
+ draggingRef.current = false;
481
+ },
482
+ onPointerCancel: () => {
483
+ draggingRef.current = false;
484
+ }
485
+ }
486
+ ),
487
+ !touch && /* @__PURE__ */ jsx(
488
+ DesktopPointerTracker,
489
+ {
490
+ onMove: (x, y) => setPos({ x, y }),
491
+ onDown: (x, y) => {
492
+ if (!armed) return;
493
+ draggingRef.current = true;
494
+ lastRef.current = { x, y, t: performance.now() };
495
+ },
496
+ onUp: (x, y) => {
497
+ if (armed && loadRef.current > 1) throwSnow(x, y);
498
+ draggingRef.current = false;
499
+ }
500
+ }
501
+ ),
502
+ /* @__PURE__ */ jsxs(
503
+ "div",
504
+ {
505
+ style: {
506
+ position: "fixed",
507
+ left: pos.x,
508
+ top: pos.y,
509
+ zIndex,
510
+ pointerEvents: "none",
511
+ transform: `translate(-30%, -30%) rotate(${armed ? -18 : -35}deg) scale(${scale})`,
512
+ opacity: armed ? 1 : 0.92,
513
+ transition: "opacity 200ms ease, transform 200ms ease",
514
+ userSelect: "none",
515
+ filter: "drop-shadow(0 10px 18px rgba(0,0,0,0.25))"
516
+ },
517
+ children: [
518
+ /* @__PURE__ */ jsx(ShovelWithSnow, { loadRatio, size: 46 }),
519
+ /* @__PURE__ */ jsx(
520
+ "div",
521
+ {
522
+ style: {
523
+ marginTop: 6,
524
+ width: 120,
525
+ height: 8,
526
+ borderRadius: 999,
527
+ background: "rgba(255,255,255,0.25)",
528
+ overflow: "hidden"
529
+ },
530
+ children: /* @__PURE__ */ jsx(
531
+ "div",
532
+ {
533
+ style: {
534
+ width: `${loadRatio * 100}%`,
535
+ height: "100%",
536
+ background: "rgba(255,255,255,0.92)"
537
+ }
538
+ }
539
+ )
540
+ }
541
+ )
542
+ ]
543
+ }
544
+ )
545
+ ] });
546
+ }
547
+ function DesktopPointerTracker({
548
+ onMove,
549
+ onDown,
550
+ onUp
551
+ }) {
552
+ useEffect(() => {
553
+ const move = (e) => onMove(e.clientX, e.clientY);
554
+ const down = (e) => onDown(e.clientX, e.clientY);
555
+ const up = (e) => onUp(e.clientX, e.clientY);
556
+ window.addEventListener("pointermove", move, { passive: true });
557
+ window.addEventListener("pointerdown", down);
558
+ window.addEventListener("pointerup", up);
559
+ return () => {
560
+ window.removeEventListener("pointermove", move);
561
+ window.removeEventListener("pointerdown", down);
562
+ window.removeEventListener("pointerup", up);
563
+ };
564
+ }, [onMove, onDown, onUp]);
565
+ return null;
566
+ }
567
+ var DEFAULT_CFG = {
568
+ gravityY: 1,
569
+ restitution: 0.35,
570
+ friction: 0.1,
571
+ frictionAir: 0.02,
572
+ ttlMs: 2500,
573
+ allowSideExit: true,
574
+ maxBodies: 200
575
+ };
576
+ function MatterSnowChunks({
577
+ zIndex = 4,
578
+ apiRef,
579
+ initialConfig
580
+ }) {
581
+ const canvasRef = useRef(null);
582
+ useEffect(() => {
583
+ const Engine = Matter.Engine;
584
+ const World = Matter.World;
585
+ const Bodies = Matter.Bodies;
586
+ const Composite = Matter.Composite;
587
+ const engine = Engine.create({ enableSleeping: true });
588
+ const cfgRef = { current: { ...DEFAULT_CFG, ...initialConfig ?? {} } };
589
+ const bornAt = /* @__PURE__ */ new WeakMap();
590
+ const order = [];
591
+ const canvas = canvasRef.current;
592
+ const ctx = canvas.getContext("2d");
593
+ const buildBoundaries = () => {
594
+ const w = window.innerWidth;
595
+ const h = window.innerHeight;
596
+ Composite.clear(engine.world, false);
597
+ const thick = 160;
598
+ const ground = Bodies.rectangle(w / 2, h + thick / 2, w + 800, thick, { isStatic: true });
599
+ World.add(engine.world, [ground]);
600
+ if (!cfgRef.current.allowSideExit) {
601
+ const wallT = 160;
602
+ const left = Bodies.rectangle(-wallT / 2, h / 2, wallT, h + 800, { isStatic: true });
603
+ const right = Bodies.rectangle(w + wallT / 2, h / 2, wallT, h + 800, { isStatic: true });
604
+ World.add(engine.world, [left, right]);
605
+ }
606
+ };
607
+ const syncSize = () => {
608
+ const w = window.innerWidth;
609
+ const h = window.innerHeight;
610
+ const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
611
+ canvas.style.width = w + "px";
612
+ canvas.style.height = h + "px";
613
+ canvas.width = Math.floor(w * dpr);
614
+ canvas.height = Math.floor(h * dpr);
615
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
616
+ buildBoundaries();
617
+ };
618
+ const applyEngineCfg = () => {
619
+ engine.gravity.y = cfgRef.current.gravityY;
620
+ };
621
+ const removeBody = (b) => {
622
+ World.remove(engine.world, b);
623
+ const idx = order.indexOf(b);
624
+ if (idx >= 0) order.splice(idx, 1);
625
+ };
626
+ const enforceCap = () => {
627
+ const cfg = cfgRef.current;
628
+ let dynCount = 0;
629
+ for (const b of order) if (!b.isStatic) dynCount++;
630
+ while (dynCount > cfg.maxBodies) {
631
+ const oldest = order.find((b) => !b.isStatic);
632
+ if (!oldest) break;
633
+ removeBody(oldest);
634
+ dynCount--;
635
+ }
636
+ };
637
+ syncSize();
638
+ applyEngineCfg();
639
+ window.addEventListener("resize", syncSize);
640
+ apiRef.current = {
641
+ throwChunks: (x, y, vx, vy, count, rBase) => {
642
+ const cfg = cfgRef.current;
643
+ for (let i = 0; i < count; i++) {
644
+ const r = rBase * (0.7 + Math.random() * 0.9);
645
+ const b = Bodies.circle(x, y, r, {
646
+ restitution: cfg.restitution,
647
+ friction: cfg.friction,
648
+ frictionAir: cfg.frictionAir
649
+ });
650
+ Matter.Body.setVelocity(b, {
651
+ x: vx * 0.9 + (Math.random() - 0.5) * 6,
652
+ y: vy * 0.7 - (8 + Math.random() * 8)
653
+ });
654
+ bornAt.set(b, performance.now());
655
+ order.push(b);
656
+ World.add(engine.world, b);
657
+ }
658
+ enforceCap();
659
+ },
660
+ setConfig: (partial) => {
661
+ const prevAllow = cfgRef.current.allowSideExit;
662
+ cfgRef.current = { ...cfgRef.current, ...partial };
663
+ applyEngineCfg();
664
+ if (partial.allowSideExit !== void 0 && partial.allowSideExit !== prevAllow) {
665
+ buildBoundaries();
666
+ }
667
+ if (partial.maxBodies !== void 0) {
668
+ enforceCap();
669
+ }
670
+ }
671
+ };
672
+ let raf = 0;
673
+ const loop = () => {
674
+ raf = requestAnimationFrame(loop);
675
+ Engine.update(engine, 1e3 / 60);
676
+ const w = window.innerWidth;
677
+ const h = window.innerHeight;
678
+ ctx.clearRect(0, 0, w, h);
679
+ ctx.fillStyle = "rgba(255,255,255,0.92)";
680
+ const now = performance.now();
681
+ const ttl = cfgRef.current.ttlMs;
682
+ for (const b of [...order]) {
683
+ if (b.isStatic) continue;
684
+ const born = bornAt.get(b) ?? now;
685
+ const age = now - born;
686
+ if (age > ttl) {
687
+ removeBody(b);
688
+ continue;
689
+ }
690
+ if (b.position.x < -500 || b.position.x > w + 500 || b.position.y > h + 800 || b.position.y < -800) {
691
+ removeBody(b);
692
+ continue;
693
+ }
694
+ const r = b.circleRadius || 3;
695
+ ctx.beginPath();
696
+ ctx.arc(b.position.x, b.position.y, r, 0, Math.PI * 2);
697
+ ctx.fill();
698
+ }
699
+ enforceCap();
700
+ };
701
+ loop();
702
+ return () => {
703
+ cancelAnimationFrame(raf);
704
+ window.removeEventListener("resize", syncSize);
705
+ apiRef.current = null;
706
+ };
707
+ }, [apiRef, initialConfig]);
708
+ return /* @__PURE__ */ jsx(
709
+ "canvas",
710
+ {
711
+ ref: canvasRef,
712
+ "aria-hidden": "true",
713
+ style: { position: "fixed", inset: 0, zIndex, pointerEvents: "none" }
714
+ }
715
+ );
716
+ }
717
+ function useWindFromScroll() {
718
+ const [windX, setWindX] = useState(0);
719
+ useEffect(() => {
720
+ let lastY = window.scrollY;
721
+ let lastT = performance.now();
722
+ const onScroll = () => {
723
+ const y = window.scrollY;
724
+ const t = performance.now();
725
+ const dt = Math.max(16, t - lastT);
726
+ const v = (y - lastY) / dt;
727
+ const w = Math.max(-2, Math.min(2, v * 20));
728
+ setWindX(w);
729
+ lastY = y;
730
+ lastT = t;
731
+ };
732
+ window.addEventListener("scroll", onScroll, { passive: true });
733
+ return () => window.removeEventListener("scroll", onScroll);
734
+ }, []);
735
+ return windX;
736
+ }
737
+ var PRESETS = {
738
+ calm: {
739
+ flakeCount: 120,
740
+ snowfallSpeed: 0.6,
741
+ snowflakeSize: [0.8, 1.8],
742
+ windStrength: 0.5,
743
+ pileMaxDepth: 160,
744
+ pileSmoothness: 0.078,
745
+ pileFPS: 20,
746
+ shovelScale: 1,
747
+ shovelRevealDepth: 9999,
748
+ chunkTtlMs: 3800,
749
+ chunkBounciness: 0.25,
750
+ chunkAirDrag: 0.03,
751
+ chunkAllowSideExit: true,
752
+ chunkGravityY: 0.9
753
+ },
754
+ cozy: {
755
+ flakeCount: 170,
756
+ snowfallSpeed: 0.9,
757
+ snowflakeSize: [0.9, 2.2],
758
+ windStrength: 0.8,
759
+ pileMaxDepth: 200,
760
+ pileSmoothness: 0.06,
761
+ pileFPS: 30,
762
+ shovelScale: 1,
763
+ shovelRevealDepth: 28,
764
+ chunkTtlMs: 4500,
765
+ chunkBounciness: 0.35,
766
+ chunkAirDrag: 0.02,
767
+ chunkAllowSideExit: true,
768
+ chunkGravityY: 1
769
+ },
770
+ storm: {
771
+ flakeCount: 240,
772
+ snowfallSpeed: 1.15,
773
+ snowflakeSize: [1, 2.6],
774
+ windStrength: 1.15,
775
+ pileMaxDepth: 220,
776
+ pileSmoothness: 0.058,
777
+ pileFPS: 30,
778
+ shovelScale: 1,
779
+ shovelRevealDepth: 22,
780
+ chunkTtlMs: 5e3,
781
+ chunkBounciness: 0.35,
782
+ chunkAirDrag: 0.02,
783
+ chunkAllowSideExit: true,
784
+ chunkGravityY: 1.05
785
+ },
786
+ interactive: {
787
+ flakeCount: 220,
788
+ snowfallSpeed: 1,
789
+ snowflakeSize: [0.9, 2.3],
790
+ windStrength: 1,
791
+ pileMaxDepth: 220,
792
+ pileSmoothness: 0.06,
793
+ pileFPS: 30,
794
+ shovelScale: 1,
795
+ shovelRevealDepth: 18,
796
+ chunkTtlMs: 4500,
797
+ chunkBounciness: 0.35,
798
+ chunkAirDrag: 0.02,
799
+ chunkAllowSideExit: true,
800
+ chunkGravityY: 1
801
+ }
802
+ };
803
+ var clamp012 = (v) => Math.max(0, Math.min(1, v));
804
+ var clampNum = (v, a, b) => Math.max(a, Math.min(b, v));
805
+ function applySafety(p) {
806
+ return {
807
+ ...p,
808
+ flakeCount: clampNum(p.flakeCount, 60, 260),
809
+ snowfallSpeed: clampNum(p.snowfallSpeed, 0.45, 1.2),
810
+ snowflakeSize: [clampNum(p.snowflakeSize[0], 0.6, 1.6), clampNum(p.snowflakeSize[1], 1.2, 3.2)],
811
+ windStrength: clampNum(p.windStrength, 0, 1.2),
812
+ pileMaxDepth: clampNum(p.pileMaxDepth, 80, 220),
813
+ pileSmoothness: clampNum(p.pileSmoothness, 0.055, 0.085),
814
+ pileFPS: clampNum(p.pileFPS, 15, 30),
815
+ // cap to protect CPU
816
+ shovelScale: clampNum(p.shovelScale, 0.85, 1.2),
817
+ shovelRevealDepth: clampNum(p.shovelRevealDepth, 12, 9999),
818
+ chunkTtlMs: clampNum(p.chunkTtlMs, 2e3, 7e3),
819
+ chunkBounciness: clampNum(p.chunkBounciness, 0.15, 0.55),
820
+ chunkAirDrag: clampNum(p.chunkAirDrag, 0.01, 0.06),
821
+ chunkAllowSideExit: p.chunkAllowSideExit,
822
+ chunkGravityY: clampNum(p.chunkGravityY, 0.7, 1.2)
823
+ };
824
+ }
825
+ function morphPreset(base, intensity = 0, speed = 0, wind = 0) {
826
+ const i = clamp012(intensity);
827
+ const s = clamp012(speed);
828
+ const w = clamp012(wind);
829
+ return applySafety({
830
+ ...base,
831
+ flakeCount: Math.round(base.flakeCount * (0.85 + 0.35 * i)),
832
+ snowfallSpeed: base.snowfallSpeed * (0.9 + 0.25 * s),
833
+ windStrength: base.windStrength * (0.9 + 0.25 * w)
834
+ });
835
+ }
836
+ function SnowStack(props) {
837
+ const {
838
+ enabled = true,
839
+ preset = "calm",
840
+ intensity = 0,
841
+ // default calm
842
+ speed = 0,
843
+ wind = 0,
844
+ interactive = false
845
+ } = props;
846
+ const base = PRESETS[interactive ? "interactive" : preset];
847
+ const cfg = useMemo(() => morphPreset(base, intensity, speed, wind), [base, intensity, speed, wind]);
848
+ const apiRef = useRef(null);
849
+ const matterRef = useRef(null);
850
+ const windFromScroll = useWindFromScroll();
851
+ const windX = useMemo(() => clamp(windFromScroll * cfg.windStrength, -2, 2), [windFromScroll, cfg.windStrength]);
852
+ const [avgDepth, setAvgDepth] = useState(0);
853
+ const shovelVisible = enabled && interactive && avgDepth >= cfg.shovelRevealDepth;
854
+ const matterInit = useMemo(
855
+ () => ({
856
+ ttlMs: cfg.chunkTtlMs,
857
+ restitution: cfg.chunkBounciness,
858
+ frictionAir: cfg.chunkAirDrag,
859
+ allowSideExit: cfg.chunkAllowSideExit,
860
+ gravityY: cfg.chunkGravityY
861
+ }),
862
+ [cfg.chunkTtlMs, cfg.chunkBounciness, cfg.chunkAirDrag, cfg.chunkAllowSideExit, cfg.chunkGravityY]
863
+ );
864
+ useEffect(() => {
865
+ matterRef.current?.setConfig(matterInit);
866
+ }, [matterInit]);
867
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
868
+ /* @__PURE__ */ jsx(
869
+ SnowfallEngine,
870
+ {
871
+ zIndex: 1,
872
+ enabled,
873
+ getAPI: () => apiRef.current,
874
+ windX,
875
+ flakeCount: cfg.flakeCount,
876
+ snowfallSpeed: cfg.snowfallSpeed,
877
+ snowflakeSize: cfg.snowflakeSize
878
+ }
879
+ ),
880
+ /* @__PURE__ */ jsx(
881
+ SnowAccumulationCanvas,
882
+ {
883
+ zIndex: 2,
884
+ enabled,
885
+ windX,
886
+ apiRef,
887
+ onAvgDepth: setAvgDepth,
888
+ pileMaxDepth: cfg.pileMaxDepth,
889
+ pileSmoothness: cfg.pileSmoothness,
890
+ pileFPS: cfg.pileFPS
891
+ }
892
+ ),
893
+ enabled && interactive && /* @__PURE__ */ jsx(MatterSnowChunks, { zIndex: 4, apiRef: matterRef, initialConfig: matterInit }),
894
+ /* @__PURE__ */ jsx(
895
+ SnowShovel,
896
+ {
897
+ zIndex: 3,
898
+ visible: shovelVisible,
899
+ scale: cfg.shovelScale,
900
+ getAPI: () => apiRef.current,
901
+ getMatter: () => matterRef.current
902
+ }
903
+ )
904
+ ] });
905
+ }
906
+
907
+ export { MatterSnowChunks, SnowStack };
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "snowstack",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Calm, interactive snowfall with accumulation and shovel physics for React",
6
+ "keywords": [
7
+ "react",
8
+ "snow",
9
+ "snowfall",
10
+ "canvas",
11
+ "matter-js",
12
+ "animation",
13
+ "winter",
14
+ "effects",
15
+ "ui"
16
+ ],
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/ist00dent/snowstack"
20
+ },
21
+ "license": "MIT",
22
+ "author": "ist00dent",
23
+ "homepage": "https://github.com/ist00dent/snowstack",
24
+ "bugs": {
25
+ "url": "https://github.com/ist00dent/snowstack/issues"
26
+ },
27
+ "files": [
28
+ "dist"
29
+ ],
30
+ "sideEffects": false,
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/index.d.ts",
34
+ "import": "./dist/index.js"
35
+ }
36
+ },
37
+ "peerDependencies": {
38
+ "react": "^18 || ^19",
39
+ "react-dom": "^18 || ^19"
40
+ },
41
+ "dependencies": {
42
+ "lucide-react": "^0.268.0",
43
+ "matter-js": "^0.19.0"
44
+ },
45
+ "scripts": {
46
+ "build": "rm -rf dist && tsup src/index.ts --format esm --dts --clean"
47
+ },
48
+ "devDependencies": {
49
+ "@types/matter-js": "^0.20.2",
50
+ "@types/react": "^19.2.7",
51
+ "tsup": "^8.5.1",
52
+ "typescript": "^5.0.0"
53
+ }
54
+ }