masoneffect 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.
@@ -0,0 +1,319 @@
1
+ /**
2
+ * MasonEffect - 파티클 모핑 효과 라이브러리
3
+ * 바닐라 JS 코어 클래스
4
+ */
5
+
6
+ export class MasonEffect {
7
+ constructor(container, options = {}) {
8
+ // 컨테이너 요소
9
+ this.container = typeof container === 'string'
10
+ ? document.querySelector(container)
11
+ : container;
12
+
13
+ if (!this.container) {
14
+ throw new Error('Container element not found');
15
+ }
16
+
17
+ // 설정값들
18
+ this.config = {
19
+ text: options.text || 'mason crawler',
20
+ densityStep: options.densityStep ?? 2,
21
+ maxParticles: options.maxParticles ?? 3200,
22
+ pointSize: options.pointSize ?? 0.5,
23
+ ease: options.ease ?? 0.05,
24
+ repelRadius: options.repelRadius ?? 150,
25
+ repelStrength: options.repelStrength ?? 1,
26
+ particleColor: options.particleColor || '#fff',
27
+ fontFamily: options.fontFamily || 'Inter, system-ui, Arial',
28
+ fontSize: options.fontSize || null, // null이면 자동 계산
29
+ width: options.width || null, // null이면 컨테이너 크기
30
+ height: options.height || null, // null이면 컨테이너 크기
31
+ devicePixelRatio: options.devicePixelRatio ?? null, // null이면 자동
32
+ onReady: options.onReady || null,
33
+ onUpdate: options.onUpdate || null,
34
+ };
35
+
36
+ // 캔버스 생성
37
+ this.canvas = document.createElement('canvas');
38
+ this.ctx = this.canvas.getContext('2d');
39
+ this.container.appendChild(this.canvas);
40
+ this.canvas.style.display = 'block';
41
+
42
+ // 오프스크린 캔버스 (텍스트 렌더링용)
43
+ this.offCanvas = document.createElement('canvas');
44
+ this.offCtx = this.offCanvas.getContext('2d');
45
+
46
+ // 상태
47
+ this.W = 0;
48
+ this.H = 0;
49
+ this.DPR = this.config.devicePixelRatio || Math.min(window.devicePixelRatio || 1, 1.8);
50
+ this.particles = [];
51
+ this.mouse = { x: 0, y: 0, down: false };
52
+ this.animationId = null;
53
+ this.isRunning = false;
54
+
55
+ // 이벤트 핸들러 바인딩
56
+ this.handleResize = this.handleResize.bind(this);
57
+ this.handleMouseMove = this.handleMouseMove.bind(this);
58
+ this.handleMouseLeave = this.handleMouseLeave.bind(this);
59
+ this.handleMouseDown = this.handleMouseDown.bind(this);
60
+ this.handleMouseUp = this.handleMouseUp.bind(this);
61
+
62
+ // 초기화
63
+ this.init();
64
+ }
65
+
66
+ init() {
67
+ this.resize();
68
+ this.setupEventListeners();
69
+ this.start();
70
+
71
+ if (this.config.onReady) {
72
+ this.config.onReady(this);
73
+ }
74
+ }
75
+
76
+ resize() {
77
+ const width = this.config.width || this.container.clientWidth || window.innerWidth;
78
+ const height = this.config.height || this.container.clientHeight || window.innerHeight * 0.7;
79
+
80
+ this.W = Math.floor(width * this.DPR);
81
+ this.H = Math.floor(height * this.DPR);
82
+
83
+ this.canvas.width = this.W;
84
+ this.canvas.height = this.H;
85
+ this.canvas.style.width = width + 'px';
86
+ this.canvas.style.height = height + 'px';
87
+
88
+ this.buildTargets();
89
+ if (!this.particles.length) {
90
+ this.initParticles();
91
+ }
92
+ }
93
+
94
+ buildTargets() {
95
+ const text = this.config.text;
96
+ this.offCanvas.width = this.W;
97
+ this.offCanvas.height = this.H;
98
+ this.offCtx.clearRect(0, 0, this.offCanvas.width, this.offCanvas.height);
99
+
100
+ const base = Math.min(this.W, this.H);
101
+ const fontSize = this.config.fontSize || Math.max(80, Math.floor(base * 0.18));
102
+ this.offCtx.fillStyle = '#ffffff';
103
+ this.offCtx.textAlign = 'center';
104
+ this.offCtx.textBaseline = 'middle';
105
+ this.offCtx.font = `400 ${fontSize}px ${this.config.fontFamily}`;
106
+
107
+ // 글자 간격 계산 및 그리기
108
+ const chars = text.split('');
109
+ const spacing = fontSize * 0.05;
110
+ const totalWidth = this.offCtx.measureText(text).width + spacing * (chars.length - 1);
111
+ let x = this.W / 2 - totalWidth / 2;
112
+
113
+ for (const ch of chars) {
114
+ this.offCtx.fillText(ch, x + this.offCtx.measureText(ch).width / 2, this.H / 2);
115
+ x += this.offCtx.measureText(ch).width + spacing;
116
+ }
117
+
118
+ // 픽셀 샘플링
119
+ const step = Math.max(2, this.config.densityStep);
120
+ const img = this.offCtx.getImageData(0, 0, this.W, this.H).data;
121
+ const targets = [];
122
+
123
+ for (let y = 0; y < this.H; y += step) {
124
+ for (let x = 0; x < this.W; x += step) {
125
+ const i = (y * this.W + x) * 4;
126
+ if (img[i] + img[i + 1] + img[i + 2] > 600) {
127
+ targets.push({ x, y });
128
+ }
129
+ }
130
+ }
131
+
132
+ // 파티클 수 제한
133
+ while (targets.length > this.config.maxParticles) {
134
+ targets.splice(Math.floor(Math.random() * targets.length), 1);
135
+ }
136
+
137
+ // 파티클 수 조정
138
+ if (this.particles.length < targets.length) {
139
+ const need = targets.length - this.particles.length;
140
+ for (let i = 0; i < need; i++) {
141
+ this.particles.push(this.makeParticle());
142
+ }
143
+ } else if (this.particles.length > targets.length) {
144
+ this.particles.length = targets.length;
145
+ }
146
+
147
+ // 목표 좌표 할당
148
+ for (let i = 0; i < this.particles.length; i++) {
149
+ const p = this.particles[i];
150
+ const t = targets[i];
151
+ p.tx = t.x;
152
+ p.ty = t.y;
153
+ }
154
+ }
155
+
156
+ makeParticle() {
157
+ const m = 0.12;
158
+ const sx = (m + Math.random() * (1 - 2 * m)) * this.W;
159
+ const sy = (m + Math.random() * (1 - 2 * m)) * this.H;
160
+ return {
161
+ x: sx,
162
+ y: sy,
163
+ vx: 0,
164
+ vy: 0,
165
+ tx: sx,
166
+ ty: sy,
167
+ j: Math.random() * Math.PI * 2,
168
+ };
169
+ }
170
+
171
+ initParticles() {
172
+ for (const p of this.particles) {
173
+ p.x = (0.12 + Math.random() * 1.76) * this.W;
174
+ p.y = (0.12 + Math.random() * 1.76) * this.H;
175
+ p.vx = p.vy = 0;
176
+ }
177
+ }
178
+
179
+ scatter() {
180
+ for (const p of this.particles) {
181
+ p.tx = (0.12 + Math.random() * 1.76) * this.W;
182
+ p.ty = (0.12 + Math.random() * 1.76) * this.H;
183
+ }
184
+ }
185
+
186
+ morph(text = null) {
187
+ if (text) {
188
+ this.config.text = text;
189
+ }
190
+ this.buildTargets();
191
+ }
192
+
193
+ update() {
194
+ this.ctx.clearRect(0, 0, this.W, this.H);
195
+
196
+ for (const p of this.particles) {
197
+ // 목표 좌표로 당기는 힘
198
+ let ax = (p.tx - p.x) * this.config.ease;
199
+ let ay = (p.ty - p.y) * this.config.ease;
200
+
201
+ // 마우스 반발/흡입
202
+ if (this.mouse.x || this.mouse.y) {
203
+ const dx = p.x - this.mouse.x;
204
+ const dy = p.y - this.mouse.y;
205
+ const d2 = dx * dx + dy * dy;
206
+ const r = this.config.repelRadius * this.DPR;
207
+ if (d2 < r * r) {
208
+ const d = Math.sqrt(d2) + 0.0001;
209
+ const f = (this.mouse.down ? -1 : 1) * this.config.repelStrength * (1 - d / r);
210
+ ax += (dx / d) * f * 6.0;
211
+ ay += (dy / d) * f * 6.0;
212
+ }
213
+ }
214
+
215
+ // 진동 효과
216
+ p.j += 2;
217
+ ax += Math.cos(p.j) * 0.05;
218
+ ay += Math.sin(p.j * 1.3) * 0.05;
219
+
220
+ // 속도와 위치 업데이트
221
+ p.vx = (p.vx + ax) * Math.random();
222
+ p.vy = (p.vy + ay) * Math.random();
223
+ p.x += p.vx;
224
+ p.y += p.vy;
225
+ }
226
+
227
+ // 파티클 그리기
228
+ this.ctx.fillStyle = this.config.particleColor;
229
+ const r = this.config.pointSize * this.DPR;
230
+ for (const p of this.particles) {
231
+ this.ctx.beginPath();
232
+ this.ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
233
+ this.ctx.fill();
234
+ }
235
+
236
+ if (this.config.onUpdate) {
237
+ this.config.onUpdate(this);
238
+ }
239
+ }
240
+
241
+ animate() {
242
+ if (!this.isRunning) return;
243
+ this.update();
244
+ this.animationId = requestAnimationFrame(() => this.animate());
245
+ }
246
+
247
+ start() {
248
+ if (this.isRunning) return;
249
+ this.isRunning = true;
250
+ this.animate();
251
+ }
252
+
253
+ stop() {
254
+ this.isRunning = false;
255
+ if (this.animationId) {
256
+ cancelAnimationFrame(this.animationId);
257
+ this.animationId = null;
258
+ }
259
+ }
260
+
261
+ setupEventListeners() {
262
+ window.addEventListener('resize', this.handleResize);
263
+ this.canvas.addEventListener('mousemove', this.handleMouseMove);
264
+ this.canvas.addEventListener('mouseleave', this.handleMouseLeave);
265
+ this.canvas.addEventListener('mousedown', this.handleMouseDown);
266
+ window.addEventListener('mouseup', this.handleMouseUp);
267
+ }
268
+
269
+ removeEventListeners() {
270
+ window.removeEventListener('resize', this.handleResize);
271
+ this.canvas.removeEventListener('mousemove', this.handleMouseMove);
272
+ this.canvas.removeEventListener('mouseleave', this.handleMouseLeave);
273
+ this.canvas.removeEventListener('mousedown', this.handleMouseDown);
274
+ window.removeEventListener('mouseup', this.handleMouseUp);
275
+ }
276
+
277
+ handleResize() {
278
+ this.resize();
279
+ }
280
+
281
+ handleMouseMove(e) {
282
+ const rect = this.canvas.getBoundingClientRect();
283
+ this.mouse.x = (e.clientX - rect.left) * this.DPR;
284
+ this.mouse.y = (e.clientY - rect.top) * this.DPR;
285
+ }
286
+
287
+ handleMouseLeave() {
288
+ this.mouse.x = this.mouse.y = 0;
289
+ }
290
+
291
+ handleMouseDown() {
292
+ this.mouse.down = true;
293
+ }
294
+
295
+ handleMouseUp() {
296
+ this.mouse.down = false;
297
+ }
298
+
299
+ // 설정 업데이트
300
+ updateConfig(newConfig) {
301
+ this.config = { ...this.config, ...newConfig };
302
+ if (newConfig.text) {
303
+ this.buildTargets();
304
+ }
305
+ }
306
+
307
+ // 파괴 및 정리
308
+ destroy() {
309
+ this.stop();
310
+ this.removeEventListeners();
311
+ if (this.canvas && this.canvas.parentNode) {
312
+ this.canvas.parentNode.removeChild(this.canvas);
313
+ }
314
+ }
315
+ }
316
+
317
+ // 기본 export
318
+ export default MasonEffect;
319
+
package/src/index.js ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * MasonEffect - 메인 엔트리 포인트
3
+ */
4
+
5
+ export { MasonEffect } from './core/index.js';
6
+ export { default } from './core/index.js';
7
+
@@ -0,0 +1,170 @@
1
+ import React, { useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
2
+ import { MasonEffect } from '../core/index.js';
3
+
4
+ export interface MasonEffectOptions {
5
+ text?: string;
6
+ densityStep?: number;
7
+ maxParticles?: number;
8
+ pointSize?: number;
9
+ ease?: number;
10
+ repelRadius?: number;
11
+ repelStrength?: number;
12
+ particleColor?: string;
13
+ fontFamily?: string;
14
+ fontSize?: number | null;
15
+ width?: number | null;
16
+ height?: number | null;
17
+ devicePixelRatio?: number | null;
18
+ onReady?: (instance: MasonEffect) => void;
19
+ onUpdate?: (instance: MasonEffect) => void;
20
+ }
21
+
22
+ export interface MasonEffectRef {
23
+ morph: (text?: string) => void;
24
+ scatter: () => void;
25
+ updateConfig: (config: Partial<MasonEffectOptions>) => void;
26
+ destroy: () => void;
27
+ }
28
+
29
+ interface MasonEffectProps extends MasonEffectOptions {
30
+ className?: string;
31
+ style?: React.CSSProperties;
32
+ }
33
+
34
+ const MasonEffectComponent = forwardRef<MasonEffectRef, MasonEffectProps>(
35
+ (props, ref) => {
36
+ const containerRef = useRef<HTMLDivElement>(null);
37
+ const instanceRef = useRef<MasonEffect | null>(null);
38
+
39
+ useEffect(() => {
40
+ if (!containerRef.current) return;
41
+
42
+ const {
43
+ className,
44
+ style,
45
+ text,
46
+ densityStep,
47
+ maxParticles,
48
+ pointSize,
49
+ ease,
50
+ repelRadius,
51
+ repelStrength,
52
+ particleColor,
53
+ fontFamily,
54
+ fontSize,
55
+ width,
56
+ height,
57
+ devicePixelRatio,
58
+ onReady,
59
+ onUpdate,
60
+ } = props;
61
+
62
+ const options: MasonEffectOptions = {
63
+ text,
64
+ densityStep,
65
+ maxParticles,
66
+ pointSize,
67
+ ease,
68
+ repelRadius,
69
+ repelStrength,
70
+ particleColor,
71
+ fontFamily,
72
+ fontSize,
73
+ width,
74
+ height,
75
+ devicePixelRatio,
76
+ onReady,
77
+ onUpdate,
78
+ };
79
+
80
+ instanceRef.current = new MasonEffect(containerRef.current, options);
81
+
82
+ return () => {
83
+ if (instanceRef.current) {
84
+ instanceRef.current.destroy();
85
+ instanceRef.current = null;
86
+ }
87
+ };
88
+ }, []);
89
+
90
+ // props 변경 시 설정 업데이트
91
+ useEffect(() => {
92
+ if (!instanceRef.current) return;
93
+
94
+ const {
95
+ text,
96
+ densityStep,
97
+ maxParticles,
98
+ pointSize,
99
+ ease,
100
+ repelRadius,
101
+ repelStrength,
102
+ particleColor,
103
+ fontFamily,
104
+ fontSize,
105
+ width,
106
+ height,
107
+ devicePixelRatio,
108
+ } = props;
109
+
110
+ instanceRef.current.updateConfig({
111
+ text,
112
+ densityStep,
113
+ maxParticles,
114
+ pointSize,
115
+ ease,
116
+ repelRadius,
117
+ repelStrength,
118
+ particleColor,
119
+ fontFamily,
120
+ fontSize,
121
+ width,
122
+ height,
123
+ devicePixelRatio,
124
+ });
125
+ }, [
126
+ props.text,
127
+ props.densityStep,
128
+ props.maxParticles,
129
+ props.pointSize,
130
+ props.ease,
131
+ props.repelRadius,
132
+ props.repelStrength,
133
+ props.particleColor,
134
+ props.fontFamily,
135
+ props.fontSize,
136
+ props.width,
137
+ props.height,
138
+ props.devicePixelRatio,
139
+ ]);
140
+
141
+ useImperativeHandle(ref, () => ({
142
+ morph: (text?: string) => {
143
+ instanceRef.current?.morph(text);
144
+ },
145
+ scatter: () => {
146
+ instanceRef.current?.scatter();
147
+ },
148
+ updateConfig: (config: Partial<MasonEffectOptions>) => {
149
+ instanceRef.current?.updateConfig(config);
150
+ },
151
+ destroy: () => {
152
+ instanceRef.current?.destroy();
153
+ instanceRef.current = null;
154
+ },
155
+ }));
156
+
157
+ return (
158
+ <div
159
+ ref={containerRef}
160
+ className={props.className}
161
+ style={props.style}
162
+ />
163
+ );
164
+ }
165
+ );
166
+
167
+ MasonEffectComponent.displayName = 'MasonEffect';
168
+
169
+ export default MasonEffectComponent;
170
+
@@ -0,0 +1,2 @@
1
+ export { default, MasonEffectRef, MasonEffectOptions } from './MasonEffect';
2
+
@@ -0,0 +1,190 @@
1
+ <template>
2
+ <div ref="container" :class="className" :style="style"></div>
3
+ </template>
4
+
5
+ <script>
6
+ import { defineComponent, ref, onMounted, onBeforeUnmount, watch } from 'vue';
7
+ import { MasonEffect } from '../core/index.js';
8
+
9
+ export default defineComponent({
10
+ name: 'MasonEffect',
11
+ props: {
12
+ text: {
13
+ type: String,
14
+ default: 'mason crawler',
15
+ },
16
+ densityStep: {
17
+ type: Number,
18
+ default: 2,
19
+ },
20
+ maxParticles: {
21
+ type: Number,
22
+ default: 3200,
23
+ },
24
+ pointSize: {
25
+ type: Number,
26
+ default: 0.5,
27
+ },
28
+ ease: {
29
+ type: Number,
30
+ default: 0.05,
31
+ },
32
+ repelRadius: {
33
+ type: Number,
34
+ default: 150,
35
+ },
36
+ repelStrength: {
37
+ type: Number,
38
+ default: 1,
39
+ },
40
+ particleColor: {
41
+ type: String,
42
+ default: '#fff',
43
+ },
44
+ fontFamily: {
45
+ type: String,
46
+ default: 'Inter, system-ui, Arial',
47
+ },
48
+ fontSize: {
49
+ type: Number,
50
+ default: null,
51
+ },
52
+ width: {
53
+ type: Number,
54
+ default: null,
55
+ },
56
+ height: {
57
+ type: Number,
58
+ default: null,
59
+ },
60
+ devicePixelRatio: {
61
+ type: Number,
62
+ default: null,
63
+ },
64
+ className: {
65
+ type: String,
66
+ default: '',
67
+ },
68
+ style: {
69
+ type: Object,
70
+ default: () => ({}),
71
+ },
72
+ onReady: {
73
+ type: Function,
74
+ default: null,
75
+ },
76
+ onUpdate: {
77
+ type: Function,
78
+ default: null,
79
+ },
80
+ },
81
+ emits: ['ready', 'update'],
82
+ setup(props, { expose, emit }) {
83
+ const container = ref(null);
84
+ let instance = null;
85
+
86
+ const init = () => {
87
+ if (!container.value) return;
88
+
89
+ const options = {
90
+ text: props.text,
91
+ densityStep: props.densityStep,
92
+ maxParticles: props.maxParticles,
93
+ pointSize: props.pointSize,
94
+ ease: props.ease,
95
+ repelRadius: props.repelRadius,
96
+ repelStrength: props.repelStrength,
97
+ particleColor: props.particleColor,
98
+ fontFamily: props.fontFamily,
99
+ fontSize: props.fontSize,
100
+ width: props.width,
101
+ height: props.height,
102
+ devicePixelRatio: props.devicePixelRatio,
103
+ onReady: (inst) => {
104
+ if (props.onReady) props.onReady(inst);
105
+ emit('ready', inst);
106
+ },
107
+ onUpdate: (inst) => {
108
+ if (props.onUpdate) props.onUpdate(inst);
109
+ emit('update', inst);
110
+ },
111
+ };
112
+
113
+ instance = new MasonEffect(container.value, options);
114
+ };
115
+
116
+ // props 변경 감지
117
+ watch(
118
+ () => [
119
+ props.text,
120
+ props.densityStep,
121
+ props.maxParticles,
122
+ props.pointSize,
123
+ props.ease,
124
+ props.repelRadius,
125
+ props.repelStrength,
126
+ props.particleColor,
127
+ props.fontFamily,
128
+ props.fontSize,
129
+ props.width,
130
+ props.height,
131
+ props.devicePixelRatio,
132
+ ],
133
+ () => {
134
+ if (instance) {
135
+ instance.updateConfig({
136
+ text: props.text,
137
+ densityStep: props.densityStep,
138
+ maxParticles: props.maxParticles,
139
+ pointSize: props.pointSize,
140
+ ease: props.ease,
141
+ repelRadius: props.repelRadius,
142
+ repelStrength: props.repelStrength,
143
+ particleColor: props.particleColor,
144
+ fontFamily: props.fontFamily,
145
+ fontSize: props.fontSize,
146
+ width: props.width,
147
+ height: props.height,
148
+ devicePixelRatio: props.devicePixelRatio,
149
+ });
150
+ }
151
+ }
152
+ );
153
+
154
+ onMounted(() => {
155
+ init();
156
+ });
157
+
158
+ onBeforeUnmount(() => {
159
+ if (instance) {
160
+ instance.destroy();
161
+ instance = null;
162
+ }
163
+ });
164
+
165
+ // 외부에서 사용할 수 있는 메서드 노출
166
+ expose({
167
+ morph: (text) => {
168
+ if (instance) instance.morph(text);
169
+ },
170
+ scatter: () => {
171
+ if (instance) instance.scatter();
172
+ },
173
+ updateConfig: (config) => {
174
+ if (instance) instance.updateConfig(config);
175
+ },
176
+ destroy: () => {
177
+ if (instance) {
178
+ instance.destroy();
179
+ instance = null;
180
+ }
181
+ },
182
+ });
183
+
184
+ return {
185
+ container,
186
+ };
187
+ },
188
+ });
189
+ </script>
190
+