masoneffect 0.1.13 → 0.1.15

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.
@@ -6,423 +6,381 @@ var React = require('react');
6
6
  * MasonEffect - 파티클 모핑 효과 라이브러리
7
7
  * 바닐라 JS 코어 클래스
8
8
  */
9
-
10
9
  // 디바운스 유틸리티 함수
11
10
  function debounce(func, wait) {
12
- let timeout;
13
- return function executedFunction(...args) {
14
- const later = () => {
15
- clearTimeout(timeout);
16
- func.apply(this, args);
11
+ let timeout = null;
12
+ return function executedFunction(...args) {
13
+ const later = () => {
14
+ timeout = null;
15
+ func.apply(this, args);
16
+ };
17
+ if (timeout !== null) {
18
+ clearTimeout(timeout);
19
+ }
20
+ timeout = setTimeout(later, wait);
17
21
  };
18
- clearTimeout(timeout);
19
- timeout = setTimeout(later, wait);
20
- };
21
22
  }
22
-
23
23
  class MasonEffect {
24
- constructor(container, options = {}) {
25
- // 컨테이너 요소
26
- this.container = typeof container === 'string'
27
- ? document.querySelector(container)
28
- : container;
29
-
30
- if (!this.container) {
31
- throw new Error('Container element not found');
32
- }
33
-
34
- // 설정값들
35
- this.config = {
36
- text: options.text || 'mason effect',
37
- densityStep: options.densityStep ?? 2,
38
- maxParticles: options.maxParticles ?? 3200,
39
- pointSize: options.pointSize ?? 0.5,
40
- ease: options.ease ?? 0.05,
41
- repelRadius: options.repelRadius ?? 150,
42
- repelStrength: options.repelStrength ?? 1,
43
- particleColor: options.particleColor || '#fff',
44
- fontFamily: options.fontFamily || 'Inter, system-ui, Arial',
45
- fontSize: options.fontSize || null, // null이면 자동 계산
46
- width: options.width || null, // null이면 컨테이너 크기
47
- height: options.height || null, // null이면 컨테이너 크기
48
- devicePixelRatio: options.devicePixelRatio ?? null, // null이면 자동
49
- onReady: options.onReady || null,
50
- onUpdate: options.onUpdate || null,
51
- };
52
-
53
- // 캔버스 생성
54
- this.canvas = document.createElement('canvas');
55
- this.ctx = this.canvas.getContext('2d');
56
- this.container.appendChild(this.canvas);
57
- this.canvas.style.display = 'block';
58
-
59
- // 오프스크린 캔버스 (텍스트 렌더링용)
60
- this.offCanvas = document.createElement('canvas');
61
- this.offCtx = this.offCanvas.getContext('2d');
62
-
63
- // 상태
64
- this.W = 0;
65
- this.H = 0;
66
- this.DPR = this.config.devicePixelRatio || Math.min(window.devicePixelRatio || 1, 1.8);
67
- this.particles = [];
68
- this.mouse = { x: 0, y: 0, down: false };
69
- this.animationId = null;
70
- this.isRunning = false;
71
- this.isVisible = false;
72
- this.intersectionObserver = null;
73
-
74
- // 디바운스 설정 (ms)
75
- this.debounceDelay = options.debounceDelay ?? 150; // 기본 150ms
76
-
77
- // 이벤트 핸들러 바인딩 (디바운스 적용 전에 바인딩)
78
- const boundHandleResize = this.handleResize.bind(this);
79
- this.handleResize = debounce(boundHandleResize, this.debounceDelay);
80
- this.handleMouseMove = this.handleMouseMove.bind(this);
81
- this.handleMouseLeave = this.handleMouseLeave.bind(this);
82
- this.handleMouseDown = this.handleMouseDown.bind(this);
83
- this.handleMouseUp = this.handleMouseUp.bind(this);
84
-
85
- // morph와 updateConfig를 위한 디바운스된 내부 메서드
86
- this._debouncedMorph = debounce(this._morphInternal.bind(this), this.debounceDelay);
87
- this._debouncedUpdateConfig = debounce(this._updateConfigInternal.bind(this), this.debounceDelay);
88
-
89
- // 초기화
90
- this.init();
91
- }
92
-
93
- init() {
94
- this.resize();
95
- this.setupEventListeners();
96
- this.setupIntersectionObserver();
97
-
98
- if (this.config.onReady) {
99
- this.config.onReady(this);
100
- }
101
- }
102
-
103
- setupIntersectionObserver() {
104
- // IntersectionObserver가 지원되지 않는 환경에서는 항상 재생
105
- if (typeof window === 'undefined' || typeof window.IntersectionObserver === 'undefined') {
106
- this.isVisible = true;
107
- this.start();
108
- return;
24
+ constructor(container, options = {}) {
25
+ // 컨테이너 요소
26
+ this.container = typeof container === 'string'
27
+ ? document.querySelector(container)
28
+ : container;
29
+ if (!this.container) {
30
+ throw new Error('Container element not found');
31
+ }
32
+ // 설정값들
33
+ this.config = {
34
+ text: options.text || 'mason effect',
35
+ densityStep: options.densityStep ?? 2,
36
+ maxParticles: options.maxParticles ?? 3200,
37
+ pointSize: options.pointSize ?? 0.5,
38
+ ease: options.ease ?? 0.05,
39
+ repelRadius: options.repelRadius ?? 150,
40
+ repelStrength: options.repelStrength ?? 1,
41
+ particleColor: options.particleColor || '#fff',
42
+ fontFamily: options.fontFamily || 'Inter, system-ui, Arial',
43
+ fontSize: options.fontSize || null,
44
+ width: options.width || null,
45
+ height: options.height || null,
46
+ devicePixelRatio: options.devicePixelRatio ?? null,
47
+ onReady: options.onReady || null,
48
+ onUpdate: options.onUpdate || null,
49
+ };
50
+ // 캔버스 생성
51
+ this.canvas = document.createElement('canvas');
52
+ const ctx = this.canvas.getContext('2d');
53
+ if (!ctx) {
54
+ throw new Error('Canvas context not available');
55
+ }
56
+ this.ctx = ctx;
57
+ this.container.appendChild(this.canvas);
58
+ this.canvas.style.display = 'block';
59
+ // 오프스크린 캔버스 (텍스트 렌더링용)
60
+ this.offCanvas = document.createElement('canvas');
61
+ const offCtx = this.offCanvas.getContext('2d');
62
+ if (!offCtx) {
63
+ throw new Error('Offscreen canvas context not available');
64
+ }
65
+ this.offCtx = offCtx;
66
+ // 상태
67
+ this.W = 0;
68
+ this.H = 0;
69
+ this.DPR = this.config.devicePixelRatio || Math.min(window.devicePixelRatio || 1, 1.8);
70
+ this.particles = [];
71
+ this.mouse = { x: 0, y: 0, down: false };
72
+ this.animationId = null;
73
+ this.isRunning = false;
74
+ this.isVisible = false;
75
+ this.intersectionObserver = null;
76
+ // 디바운스 설정 (ms)
77
+ this.debounceDelay = options.debounceDelay ?? 150;
78
+ // 이벤트 핸들러 바인딩 (디바운스 적용 전에 바인딩)
79
+ const boundHandleResize = this.handleResize.bind(this);
80
+ this.handleResize = debounce(boundHandleResize, this.debounceDelay);
81
+ this.handleMouseMove = this.handleMouseMove.bind(this);
82
+ this.handleMouseLeave = this.handleMouseLeave.bind(this);
83
+ this.handleMouseDown = this.handleMouseDown.bind(this);
84
+ this.handleMouseUp = this.handleMouseUp.bind(this);
85
+ // morph와 updateConfig를 위한 디바운스된 내부 메서드
86
+ this._debouncedMorph = debounce(this._morphInternal.bind(this), this.debounceDelay);
87
+ this._debouncedUpdateConfig = debounce(this._updateConfigInternal.bind(this), this.debounceDelay);
88
+ // 초기화
89
+ this.init();
109
90
  }
110
-
111
- // 이미 설정되어 있다면 다시 만들지 않음
112
- if (this.intersectionObserver) {
113
- return;
91
+ init() {
92
+ this.resize();
93
+ this.setupEventListeners();
94
+ this.setupIntersectionObserver();
95
+ if (this.config.onReady) {
96
+ this.config.onReady(this);
97
+ }
114
98
  }
115
-
116
- this.intersectionObserver = new IntersectionObserver(
117
- (entries) => {
118
- for (const entry of entries) {
119
- if (entry.target !== this.container) continue;
120
-
121
- if (entry.isIntersecting) {
99
+ setupIntersectionObserver() {
100
+ // IntersectionObserver가 지원되지 않는 환경에서는 항상 재생
101
+ if (typeof window === 'undefined' || typeof window.IntersectionObserver === 'undefined') {
122
102
  this.isVisible = true;
123
103
  this.start();
124
- } else {
125
- this.isVisible = false;
126
- this.stop();
127
- }
104
+ return;
128
105
  }
129
- },
130
- {
131
- threshold: 0.1, // 10% 이상 보일 때 동작
132
- }
133
- );
134
-
135
- this.intersectionObserver.observe(this.container);
136
- }
137
-
138
- resize() {
139
- const width = this.config.width || this.container.clientWidth || window.innerWidth;
140
- const height = this.config.height || this.container.clientHeight || window.innerHeight * 0.7;
141
-
142
- this.W = Math.floor(width * this.DPR);
143
- this.H = Math.floor(height * this.DPR);
144
-
145
- this.canvas.width = this.W;
146
- this.canvas.height = this.H;
147
- this.canvas.style.width = width + 'px';
148
- this.canvas.style.height = height + 'px';
149
-
150
- this.buildTargets();
151
- if (!this.particles.length) {
152
- this.initParticles();
106
+ // 이미 설정되어 있다면 다시 만들지 않음
107
+ if (this.intersectionObserver) {
108
+ return;
109
+ }
110
+ this.intersectionObserver = new IntersectionObserver((entries) => {
111
+ for (const entry of entries) {
112
+ if (entry.target !== this.container)
113
+ continue;
114
+ if (entry.isIntersecting) {
115
+ this.isVisible = true;
116
+ this.start();
117
+ }
118
+ else {
119
+ this.isVisible = false;
120
+ this.stop();
121
+ }
122
+ }
123
+ }, {
124
+ threshold: 0.1, // 10% 이상 보일 때 동작
125
+ });
126
+ this.intersectionObserver.observe(this.container);
153
127
  }
154
- }
155
-
156
- buildTargets() {
157
- const text = this.config.text;
158
- this.offCanvas.width = this.W;
159
- this.offCanvas.height = this.H;
160
- this.offCtx.clearRect(0, 0, this.offCanvas.width, this.offCanvas.height);
161
-
162
- const base = Math.min(this.W, this.H);
163
- const fontSize = this.config.fontSize || Math.max(80, Math.floor(base * 0.18));
164
- this.offCtx.fillStyle = '#ffffff';
165
- this.offCtx.textAlign = 'center';
166
- this.offCtx.textBaseline = 'middle';
167
- this.offCtx.font = `400 ${fontSize}px ${this.config.fontFamily}`;
168
-
169
- // 글자 간격 계산 및 그리기
170
- const chars = text.split('');
171
- const spacing = fontSize * 0.05;
172
- const totalWidth = this.offCtx.measureText(text).width + spacing * (chars.length - 1);
173
- let x = this.W / 2 - totalWidth / 2;
174
-
175
- for (const ch of chars) {
176
- this.offCtx.fillText(ch, x + this.offCtx.measureText(ch).width / 2, this.H / 2);
177
- x += this.offCtx.measureText(ch).width + spacing;
128
+ resize() {
129
+ const width = this.config.width || this.container.clientWidth || window.innerWidth;
130
+ const height = this.config.height || this.container.clientHeight || window.innerHeight * 0.7;
131
+ this.W = Math.floor(width * this.DPR);
132
+ this.H = Math.floor(height * this.DPR);
133
+ this.canvas.width = this.W;
134
+ this.canvas.height = this.H;
135
+ this.canvas.style.width = width + 'px';
136
+ this.canvas.style.height = height + 'px';
137
+ this.buildTargets();
138
+ if (!this.particles.length) {
139
+ this.initParticles();
140
+ }
178
141
  }
179
-
180
- // 픽셀 샘플링
181
- const step = Math.max(2, this.config.densityStep);
182
- const img = this.offCtx.getImageData(0, 0, this.W, this.H).data;
183
- const targets = [];
184
-
185
- for (let y = 0; y < this.H; y += step) {
186
- for (let x = 0; x < this.W; x += step) {
187
- const i = (y * this.W + x) * 4;
188
- if (img[i] + img[i + 1] + img[i + 2] > 600) {
189
- targets.push({ x, y });
142
+ buildTargets() {
143
+ const text = this.config.text;
144
+ this.offCanvas.width = this.W;
145
+ this.offCanvas.height = this.H;
146
+ this.offCtx.clearRect(0, 0, this.offCanvas.width, this.offCanvas.height);
147
+ const base = Math.min(this.W, this.H);
148
+ const fontSize = this.config.fontSize || Math.max(80, Math.floor(base * 0.18));
149
+ this.offCtx.fillStyle = '#ffffff';
150
+ this.offCtx.textAlign = 'center';
151
+ this.offCtx.textBaseline = 'middle';
152
+ this.offCtx.font = `400 ${fontSize}px ${this.config.fontFamily}`;
153
+ // 글자 간격 계산 및 그리기
154
+ const chars = text.split('');
155
+ const spacing = fontSize * 0.05;
156
+ const totalWidth = this.offCtx.measureText(text).width + spacing * (chars.length - 1);
157
+ let x = this.W / 2 - totalWidth / 2;
158
+ for (const ch of chars) {
159
+ this.offCtx.fillText(ch, x + this.offCtx.measureText(ch).width / 2, this.H / 2);
160
+ x += this.offCtx.measureText(ch).width + spacing;
161
+ }
162
+ // 픽셀 샘플링
163
+ const step = Math.max(2, this.config.densityStep);
164
+ const img = this.offCtx.getImageData(0, 0, this.W, this.H).data;
165
+ const targets = [];
166
+ for (let y = 0; y < this.H; y += step) {
167
+ for (let x = 0; x < this.W; x += step) {
168
+ const i = (y * this.W + x) * 4;
169
+ if (img[i] + img[i + 1] + img[i + 2] > 600) {
170
+ targets.push({ x, y });
171
+ }
172
+ }
173
+ }
174
+ // 파티클 수 제한
175
+ while (targets.length > this.config.maxParticles) {
176
+ targets.splice(Math.floor(Math.random() * targets.length), 1);
177
+ }
178
+ // 파티클 수 조정
179
+ if (this.particles.length < targets.length) {
180
+ const need = targets.length - this.particles.length;
181
+ for (let i = 0; i < need; i++) {
182
+ this.particles.push(this.makeParticle());
183
+ }
184
+ }
185
+ else if (this.particles.length > targets.length) {
186
+ this.particles.length = targets.length;
187
+ }
188
+ // 목표 좌표 할당
189
+ for (let i = 0; i < this.particles.length; i++) {
190
+ const p = this.particles[i];
191
+ const t = targets[i];
192
+ p.tx = t.x;
193
+ p.ty = t.y;
190
194
  }
191
- }
192
195
  }
193
-
194
- // 파티클 제한
195
- while (targets.length > this.config.maxParticles) {
196
- targets.splice(Math.floor(Math.random() * targets.length), 1);
196
+ makeParticle() {
197
+ // 캔버스 전체에 골고루 분포 (여백 없이)
198
+ const sx = Math.random() * this.W;
199
+ const sy = Math.random() * this.H;
200
+ return {
201
+ x: sx,
202
+ y: sy,
203
+ vx: 0,
204
+ vy: 0,
205
+ tx: sx,
206
+ ty: sy,
207
+ initialX: sx, // 초기 위치 저장 (scatter 시 돌아갈 위치)
208
+ initialY: sy,
209
+ j: Math.random() * Math.PI * 2,
210
+ };
197
211
  }
198
-
199
- // 파티클 조정
200
- if (this.particles.length < targets.length) {
201
- const need = targets.length - this.particles.length;
202
- for (let i = 0; i < need; i++) {
203
- this.particles.push(this.makeParticle());
204
- }
205
- } else if (this.particles.length > targets.length) {
206
- this.particles.length = targets.length;
212
+ initParticles() {
213
+ // 캔버스 전체에 골고루 분포 (여백 없이)
214
+ for (const p of this.particles) {
215
+ const sx = Math.random() * this.W;
216
+ const sy = Math.random() * this.H;
217
+ p.x = sx;
218
+ p.y = sy;
219
+ p.vx = p.vy = 0;
220
+ // 초기 위치 저장 (scatter 시 돌아갈 위치)
221
+ p.initialX = sx;
222
+ p.initialY = sy;
223
+ }
207
224
  }
208
-
209
- // 목표 좌표 할당
210
- for (let i = 0; i < this.particles.length; i++) {
211
- const p = this.particles[i];
212
- const t = targets[i];
213
- p.tx = t.x;
214
- p.ty = t.y;
225
+ scatter() {
226
+ // 파티클을 초기 위치로 돌아가도록 설정
227
+ for (const p of this.particles) {
228
+ // 초기 위치가 저장되어 있으면 그 위치로, 없으면 현재 위치 유지
229
+ if (p.initialX !== undefined && p.initialY !== undefined) {
230
+ p.tx = p.initialX;
231
+ p.ty = p.initialY;
232
+ }
233
+ else {
234
+ // 초기 위치가 없으면 현재 위치를 초기 위치로 저장
235
+ p.initialX = p.x;
236
+ p.initialY = p.y;
237
+ p.tx = p.initialX;
238
+ p.ty = p.initialY;
239
+ }
240
+ }
215
241
  }
216
- }
217
-
218
- makeParticle() {
219
- // 캔버스 전체에 골고루 분포 (여백 없이)
220
- const sx = Math.random() * this.W;
221
- const sy = Math.random() * this.H;
222
- return {
223
- x: sx,
224
- y: sy,
225
- vx: 0,
226
- vy: 0,
227
- tx: sx,
228
- ty: sy,
229
- initialX: sx, // 초기 위치 저장 (scatter 시 돌아갈 위치)
230
- initialY: sy,
231
- j: Math.random() * Math.PI * 2,
232
- };
233
- }
234
-
235
- initParticles() {
236
- // 캔버스 전체에 골고루 분포 (여백 없이)
237
- for (const p of this.particles) {
238
- const sx = Math.random() * this.W;
239
- const sy = Math.random() * this.H;
240
- p.x = sx;
241
- p.y = sy;
242
- p.vx = p.vy = 0;
243
- // 초기 위치 저장 (scatter 시 돌아갈 위치)
244
- p.initialX = sx;
245
- p.initialY = sy;
242
+ morph(textOrOptions) {
243
+ // 즉시 실행이 필요한 경우 (예: 초기화 시)를 위해 내부 메서드 직접 호출
244
+ // 일반적인 경우에는 디바운스 적용
245
+ this._debouncedMorph(textOrOptions);
246
246
  }
247
- }
248
-
249
- scatter() {
250
- // 각 파티클을 초기 위치로 돌아가도록 설정
251
- for (const p of this.particles) {
252
- // 초기 위치가 저장되어 있으면 그 위치로, 없으면 현재 위치 유지
253
- if (p.initialX !== undefined && p.initialY !== undefined) {
254
- p.tx = p.initialX;
255
- p.ty = p.initialY;
256
- } else {
257
- // 초기 위치가 없으면 현재 위치를 초기 위치로 저장
258
- p.initialX = p.x;
259
- p.initialY = p.y;
260
- p.tx = p.initialX;
261
- p.ty = p.initialY;
262
- }
247
+ _morphInternal(textOrOptions) {
248
+ // W와 H가 0이면 resize 먼저 실행
249
+ if (this.W === 0 || this.H === 0) {
250
+ this.resize();
251
+ }
252
+ if (typeof textOrOptions === 'string') {
253
+ // 문자열인 경우: 기존 동작 유지
254
+ this.config.text = textOrOptions;
255
+ this.buildTargets();
256
+ }
257
+ else if (textOrOptions && typeof textOrOptions === 'object') {
258
+ // 객체인 경우: 텍스트와 함께 다른 설정도 변경
259
+ const needsRebuild = textOrOptions.text !== undefined;
260
+ this.config = { ...this.config, ...textOrOptions };
261
+ if (needsRebuild) {
262
+ this.buildTargets();
263
+ }
264
+ }
265
+ else {
266
+ // null이거나 undefined인 경우: 현재 텍스트로 재빌드
267
+ this.buildTargets();
268
+ }
263
269
  }
264
- }
265
-
266
- morph(textOrOptions = null) {
267
- // 즉시 실행이 필요한 경우 (예: 초기화 시)를 위해 내부 메서드 직접 호출
268
- // 일반적인 경우에는 디바운스 적용
269
- this._debouncedMorph(textOrOptions);
270
- }
271
-
272
- _morphInternal(textOrOptions = null) {
273
- // W와 H가 0이면 resize 먼저 실행
274
- if (this.W === 0 || this.H === 0) {
275
- this.resize();
270
+ update() {
271
+ this.ctx.clearRect(0, 0, this.W, this.H);
272
+ for (const p of this.particles) {
273
+ // 목표 좌표로 당기는
274
+ let ax = (p.tx - p.x) * this.config.ease;
275
+ let ay = (p.ty - p.y) * this.config.ease;
276
+ // 마우스 반발/흡입
277
+ if (this.mouse.x || this.mouse.y) {
278
+ const dx = p.x - this.mouse.x;
279
+ const dy = p.y - this.mouse.y;
280
+ const d2 = dx * dx + dy * dy;
281
+ const r = this.config.repelRadius * this.DPR;
282
+ if (d2 < r * r) {
283
+ const d = Math.sqrt(d2) + 0.0001;
284
+ const f = (this.mouse.down ? -1 : 1) * this.config.repelStrength * (1 - d / r);
285
+ ax += (dx / d) * f * 6.0;
286
+ ay += (dy / d) * f * 6.0;
287
+ }
288
+ }
289
+ // 진동 효과
290
+ p.j += 2;
291
+ ax += Math.cos(p.j) * 0.05;
292
+ ay += Math.sin(p.j * 1.3) * 0.05;
293
+ // 속도와 위치 업데이트
294
+ p.vx = (p.vx + ax) * Math.random();
295
+ p.vy = (p.vy + ay) * Math.random();
296
+ p.x += p.vx;
297
+ p.y += p.vy;
298
+ }
299
+ // 파티클 그리기
300
+ this.ctx.fillStyle = this.config.particleColor;
301
+ const r = this.config.pointSize * this.DPR;
302
+ for (const p of this.particles) {
303
+ this.ctx.beginPath();
304
+ this.ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
305
+ this.ctx.fill();
306
+ }
307
+ if (this.config.onUpdate) {
308
+ this.config.onUpdate(this);
309
+ }
276
310
  }
277
-
278
- if (typeof textOrOptions === 'string') {
279
- // 문자열인 경우: 기존 동작 유지
280
- this.config.text = textOrOptions;
281
- this.buildTargets();
282
- } else if (textOrOptions && typeof textOrOptions === 'object') {
283
- // 객체인 경우: 텍스트와 함께 다른 설정도 변경
284
- const needsRebuild = textOrOptions.text !== undefined;
285
- this.config = { ...this.config, ...textOrOptions };
286
- if (needsRebuild) {
287
- this.buildTargets();
288
- }
289
- } else {
290
- // null이거나 undefined인 경우: 현재 텍스트로 재빌드
291
- this.buildTargets();
311
+ animate() {
312
+ if (!this.isRunning)
313
+ return;
314
+ this.update();
315
+ this.animationId = requestAnimationFrame(() => this.animate());
292
316
  }
293
- }
294
-
295
- update() {
296
- this.ctx.clearRect(0, 0, this.W, this.H);
297
-
298
- for (const p of this.particles) {
299
- // 목표 좌표로 당기는 힘
300
- let ax = (p.tx - p.x) * this.config.ease;
301
- let ay = (p.ty - p.y) * this.config.ease;
302
-
303
- // 마우스 반발/흡입
304
- if (this.mouse.x || this.mouse.y) {
305
- const dx = p.x - this.mouse.x;
306
- const dy = p.y - this.mouse.y;
307
- const d2 = dx * dx + dy * dy;
308
- const r = this.config.repelRadius * this.DPR;
309
- if (d2 < r * r) {
310
- const d = Math.sqrt(d2) + 0.0001;
311
- const f = (this.mouse.down ? -1 : 1) * this.config.repelStrength * (1 - d / r);
312
- ax += (dx / d) * f * 6.0;
313
- ay += (dy / d) * f * 6.0;
317
+ start() {
318
+ if (this.isRunning)
319
+ return;
320
+ this.isRunning = true;
321
+ this.animate();
322
+ }
323
+ stop() {
324
+ this.isRunning = false;
325
+ if (this.animationId) {
326
+ cancelAnimationFrame(this.animationId);
327
+ this.animationId = null;
314
328
  }
315
- }
316
-
317
- // 진동 효과
318
- p.j += 2;
319
- ax += Math.cos(p.j) * 0.05;
320
- ay += Math.sin(p.j * 1.3) * 0.05;
321
-
322
- // 속도와 위치 업데이트
323
- p.vx = (p.vx + ax) * Math.random();
324
- p.vy = (p.vy + ay) * Math.random();
325
- p.x += p.vx;
326
- p.y += p.vy;
327
329
  }
328
-
329
- // 파티클 그리기
330
- this.ctx.fillStyle = this.config.particleColor;
331
- const r = this.config.pointSize * this.DPR;
332
- for (const p of this.particles) {
333
- this.ctx.beginPath();
334
- this.ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
335
- this.ctx.fill();
330
+ setupEventListeners() {
331
+ window.addEventListener('resize', this.handleResize);
332
+ this.canvas.addEventListener('mousemove', this.handleMouseMove);
333
+ this.canvas.addEventListener('mouseleave', this.handleMouseLeave);
334
+ this.canvas.addEventListener('mousedown', this.handleMouseDown);
335
+ window.addEventListener('mouseup', this.handleMouseUp);
336
336
  }
337
-
338
- if (this.config.onUpdate) {
339
- this.config.onUpdate(this);
337
+ removeEventListeners() {
338
+ window.removeEventListener('resize', this.handleResize);
339
+ this.canvas.removeEventListener('mousemove', this.handleMouseMove);
340
+ this.canvas.removeEventListener('mouseleave', this.handleMouseLeave);
341
+ this.canvas.removeEventListener('mousedown', this.handleMouseDown);
342
+ window.removeEventListener('mouseup', this.handleMouseUp);
340
343
  }
341
- }
342
-
343
- animate() {
344
- if (!this.isRunning) return;
345
- this.update();
346
- this.animationId = requestAnimationFrame(() => this.animate());
347
- }
348
-
349
- start() {
350
- if (this.isRunning) return;
351
- this.isRunning = true;
352
- this.animate();
353
- }
354
-
355
- stop() {
356
- this.isRunning = false;
357
- if (this.animationId) {
358
- cancelAnimationFrame(this.animationId);
359
- this.animationId = null;
344
+ handleResize() {
345
+ this.resize();
360
346
  }
361
- }
362
-
363
- setupEventListeners() {
364
- window.addEventListener('resize', this.handleResize);
365
- this.canvas.addEventListener('mousemove', this.handleMouseMove);
366
- this.canvas.addEventListener('mouseleave', this.handleMouseLeave);
367
- this.canvas.addEventListener('mousedown', this.handleMouseDown);
368
- window.addEventListener('mouseup', this.handleMouseUp);
369
- }
370
-
371
- removeEventListeners() {
372
- window.removeEventListener('resize', this.handleResize);
373
- this.canvas.removeEventListener('mousemove', this.handleMouseMove);
374
- this.canvas.removeEventListener('mouseleave', this.handleMouseLeave);
375
- this.canvas.removeEventListener('mousedown', this.handleMouseDown);
376
- window.removeEventListener('mouseup', this.handleMouseUp);
377
- }
378
-
379
- handleResize() {
380
- this.resize();
381
- }
382
-
383
- handleMouseMove(e) {
384
- const rect = this.canvas.getBoundingClientRect();
385
- this.mouse.x = (e.clientX - rect.left) * this.DPR;
386
- this.mouse.y = (e.clientY - rect.top) * this.DPR;
387
- }
388
-
389
- handleMouseLeave() {
390
- this.mouse.x = this.mouse.y = 0;
391
- }
392
-
393
- handleMouseDown() {
394
- this.mouse.down = true;
395
- }
396
-
397
- handleMouseUp() {
398
- this.mouse.down = false;
399
- }
400
-
401
- // 설정 업데이트
402
- updateConfig(newConfig) {
403
- // 디바운스 적용
404
- this._debouncedUpdateConfig(newConfig);
405
- }
406
-
407
- _updateConfigInternal(newConfig) {
408
- this.config = { ...this.config, ...newConfig };
409
- if (newConfig.text) {
410
- this.buildTargets();
347
+ handleMouseMove(e) {
348
+ const rect = this.canvas.getBoundingClientRect();
349
+ this.mouse.x = (e.clientX - rect.left) * this.DPR;
350
+ this.mouse.y = (e.clientY - rect.top) * this.DPR;
411
351
  }
412
- }
413
-
414
- // 파괴 및 정리
415
- destroy() {
416
- this.stop();
417
- this.removeEventListeners();
418
- if (this.intersectionObserver) {
419
- this.intersectionObserver.disconnect();
420
- this.intersectionObserver = null;
352
+ handleMouseLeave() {
353
+ this.mouse.x = this.mouse.y = 0;
354
+ }
355
+ handleMouseDown() {
356
+ this.mouse.down = true;
421
357
  }
422
- if (this.canvas && this.canvas.parentNode) {
423
- this.canvas.parentNode.removeChild(this.canvas);
358
+ handleMouseUp() {
359
+ this.mouse.down = false;
360
+ }
361
+ // 설정 업데이트
362
+ updateConfig(newConfig) {
363
+ // 디바운스 적용
364
+ this._debouncedUpdateConfig(newConfig);
365
+ }
366
+ _updateConfigInternal(newConfig) {
367
+ this.config = { ...this.config, ...newConfig };
368
+ if (newConfig.text) {
369
+ this.buildTargets();
370
+ }
371
+ }
372
+ // 파괴 및 정리
373
+ destroy() {
374
+ this.stop();
375
+ this.removeEventListeners();
376
+ if (this.intersectionObserver) {
377
+ this.intersectionObserver.disconnect();
378
+ this.intersectionObserver = null;
379
+ }
380
+ if (this.canvas && this.canvas.parentNode) {
381
+ this.canvas.parentNode.removeChild(this.canvas);
382
+ }
424
383
  }
425
- }
426
384
  }
427
385
 
428
386
  const MasonEffectComponent = React.forwardRef((props, ref) => {