feat: Add mouse interaction for physics-based distortion

- 마우스 움직임에 따라 왜곡 영역이 튕기는 효과를 추가했습니다.
- `useMouseVelocity` 훅을 사용하여 마우스 속도와 가속도를 추적합니다.
- `SpringPhysics` 클래스를 구현하여 스프링 기반 물리 효과를 시뮬레이션합니다.
- `useMouseInteraction` 훅은 마우스 이벤트를 감지하고 `SpringPhysics`를 제어하여 왜곡 영역의 `dragVector`를 업데이트합니다.
- `ImageDistortion` 컴포넌트에서 `mouseInteraction` prop을 통해 이 기능을 활성화/설정할 수 있습니다.
This commit is contained in:
BaekRyang 2025-11-05 14:56:36 +09:00
parent e531a7a762
commit 7f6a72c058
14 changed files with 1839 additions and 123 deletions

139
dist/index.d.mts vendored
View File

@ -1,4 +1,4 @@
import React from 'react'; import React$1 from 'react';
import * as THREE from 'three'; import * as THREE from 'three';
/** /**
@ -107,6 +107,65 @@ interface AnimationTicker {
resume: () => void; resume: () => void;
} }
/**
*
*/
interface SpringPhysicsConfig {
/** 스프링 탄성 계수 (높을수록 빠르게 복원) */
stiffness: number;
/** 감쇠 계수 (높을수록 빨리 멈춤) */
damping: number;
/** 질량 (높을수록 느리게 움직임) */
mass: number;
/** 영향 반경 (정규화 좌표, 기본값 0.2) */
influenceRadius: number;
/** 최대 왜곡 강도 */
maxStrength: number;
}
/**
*
*/
interface MouseInteractionConfig {
/** 마우스 인터랙션 활성화 여부 */
enabled: boolean;
/** 스프링 물리 파라미터 */
physics: SpringPhysicsConfig;
/** 최소 속도 임계값 (이보다 느리면 효과 없음) */
minVelocity?: number;
/** 최대 속도 제한 (이보다 빠르면 클램핑) */
maxVelocity?: number;
/** 속도 승수 (마우스 속도에 곱해지는 값) */
velocityMultiplier?: number;
}
/**
*
*/
interface MouseState {
/** 현재 마우스 위치 (정규화 좌표) */
position: Point | null;
/** 이전 마우스 위치 */
prevPosition: Point | null;
/** 속도 벡터 */
velocity: Point;
/** 가속도 벡터 */
acceleration: Point;
/** 마우스가 컨테이너 위에 있는지 */
isHovering: boolean;
/** 드래그 중인지 */
isDragging: boolean;
}
/**
*
*/
interface SpringState {
/** 현재 변위 (displacement) */
displacement: Point;
/** 현재 속도 */
velocity: Point;
/** 목표 위치 (평형 상태는 {x:0, y:0}) */
target: Point;
}
/** /**
* ImageDistortion Props * ImageDistortion Props
*/ */
@ -122,15 +181,17 @@ interface ImageDistortionProps {
/** 애니메이션 재생 여부 */ /** 애니메이션 재생 여부 */
isPlaying?: boolean; isPlaying?: boolean;
/** 컨테이너 스타일 */ /** 컨테이너 스타일 */
style?: React.CSSProperties; style?: React$1.CSSProperties;
/** 컨테이너 클래스명 */ /** 컨테이너 클래스명 */
className?: string; className?: string;
/** 마우스 인터랙션 설정 */
mouseInteraction?: MouseInteractionConfig;
} }
/** /**
* GPU * GPU
* Three.js와 GLSL . * Three.js와 GLSL .
*/ */
declare const ImageDistortion: React.FC<ImageDistortionProps>; declare const ImageDistortion: React$1.FC<ImageDistortionProps>;
/** /**
* *
@ -248,7 +309,7 @@ interface DistortionEditorProps {
canvasStyle?: EditorCanvasStyle; canvasStyle?: EditorCanvasStyle;
} }
declare const DistortionEditor: React.FC<DistortionEditorProps>; declare const DistortionEditor: React$1.FC<DistortionEditorProps>;
declare const useDistortionEditor: (initialAreas?: DistortionArea[]) => { declare const useDistortionEditor: (initialAreas?: DistortionArea[]) => {
state: EditorState; state: EditorState;
@ -403,6 +464,57 @@ declare class AnimationLoop {
static updateProgress(areas: DistortionArea[], deltaTime: number): DistortionArea[]; static updateProgress(areas: DistortionArea[], deltaTime: number): DistortionArea[];
} }
/**
*
* Hooke's Law와 -
*/
declare class SpringPhysics {
private config;
private state;
constructor(config: SpringPhysicsConfig);
/**
*
*/
setConfig(config: Partial<SpringPhysicsConfig>): void;
/**
* ( )
*/
setTarget(velocity: Point, velocityMultiplier?: number): void;
/**
* ( )
*
*/
setInitialVelocity(velocity: Point, multiplier?: number): void;
/**
* (Hooke's Law + Damping)
* F = -k * x - c * v
* a = F / m
* v += a * dt
* x += v * dt
*/
update(deltaTime: number): Point;
/**
* ( )
*/
applyImpulse(acceleration: Point, multiplier?: number): void;
/**
*
*/
getDisplacement(): Point;
/**
*
*/
getVelocity(): Point;
/**
*
*/
reset(): void;
/**
* 0 ( )
*/
returnToEquilibrium(): void;
}
/** /**
* requestAnimationFrame을 * requestAnimationFrame을
* @param callback (deltaTime을 ) * @param callback (deltaTime을 )
@ -410,4 +522,21 @@ declare class AnimationLoop {
*/ */
declare const useAnimationFrame: (callback: (deltaTime: number) => void, isPlaying?: boolean) => void; declare const useAnimationFrame: (callback: (deltaTime: number) => void, isPlaying?: boolean) => void;
export { ANIMATION_CONFIG, AnimationLoop, type AnimationState, type AnimationTicker, type AreaBounds, DEFAULT_AREA, type DistortionArea, DistortionEditor, type DistortionEditorProps, type DistortionMovement, type EasingFunction, type EditMode, type EditorState, ImageDistortion, type ImageDistortionProps, type Point, SHADER_CONFIG, type ShaderConfig, ShaderManager, type ShaderUniforms, ThreeScene, applyEasing, useAnimationFrame, useDistortionEditor }; /**
* , ,
*/
declare const useMouseVelocity: (containerRef: React.RefObject<HTMLElement | null>) => {
getState: () => MouseState;
};
/**
*
*
*/
declare const useMouseInteraction: (containerRef: React.RefObject<HTMLElement | null>, config: MouseInteractionConfig) => {
updateInteraction: (areas: DistortionArea[], deltaTime: number) => DistortionArea[];
updateConfig: (newConfig: Partial<MouseInteractionConfig>) => void;
reset: () => void;
};
export { ANIMATION_CONFIG, AnimationLoop, type AnimationState, type AnimationTicker, type AreaBounds, DEFAULT_AREA, type DistortionArea, DistortionEditor, type DistortionEditorProps, type DistortionMovement, type EasingFunction, type EditMode, type EditorState, ImageDistortion, type ImageDistortionProps, type MouseInteractionConfig, type MouseState, type Point, SHADER_CONFIG, type ShaderConfig, ShaderManager, type ShaderUniforms, SpringPhysics, type SpringPhysicsConfig, type SpringState, ThreeScene, applyEasing, useAnimationFrame, useDistortionEditor, useMouseInteraction, useMouseVelocity };

139
dist/index.d.ts vendored
View File

@ -1,4 +1,4 @@
import React from 'react'; import React$1 from 'react';
import * as THREE from 'three'; import * as THREE from 'three';
/** /**
@ -107,6 +107,65 @@ interface AnimationTicker {
resume: () => void; resume: () => void;
} }
/**
*
*/
interface SpringPhysicsConfig {
/** 스프링 탄성 계수 (높을수록 빠르게 복원) */
stiffness: number;
/** 감쇠 계수 (높을수록 빨리 멈춤) */
damping: number;
/** 질량 (높을수록 느리게 움직임) */
mass: number;
/** 영향 반경 (정규화 좌표, 기본값 0.2) */
influenceRadius: number;
/** 최대 왜곡 강도 */
maxStrength: number;
}
/**
*
*/
interface MouseInteractionConfig {
/** 마우스 인터랙션 활성화 여부 */
enabled: boolean;
/** 스프링 물리 파라미터 */
physics: SpringPhysicsConfig;
/** 최소 속도 임계값 (이보다 느리면 효과 없음) */
minVelocity?: number;
/** 최대 속도 제한 (이보다 빠르면 클램핑) */
maxVelocity?: number;
/** 속도 승수 (마우스 속도에 곱해지는 값) */
velocityMultiplier?: number;
}
/**
*
*/
interface MouseState {
/** 현재 마우스 위치 (정규화 좌표) */
position: Point | null;
/** 이전 마우스 위치 */
prevPosition: Point | null;
/** 속도 벡터 */
velocity: Point;
/** 가속도 벡터 */
acceleration: Point;
/** 마우스가 컨테이너 위에 있는지 */
isHovering: boolean;
/** 드래그 중인지 */
isDragging: boolean;
}
/**
*
*/
interface SpringState {
/** 현재 변위 (displacement) */
displacement: Point;
/** 현재 속도 */
velocity: Point;
/** 목표 위치 (평형 상태는 {x:0, y:0}) */
target: Point;
}
/** /**
* ImageDistortion Props * ImageDistortion Props
*/ */
@ -122,15 +181,17 @@ interface ImageDistortionProps {
/** 애니메이션 재생 여부 */ /** 애니메이션 재생 여부 */
isPlaying?: boolean; isPlaying?: boolean;
/** 컨테이너 스타일 */ /** 컨테이너 스타일 */
style?: React.CSSProperties; style?: React$1.CSSProperties;
/** 컨테이너 클래스명 */ /** 컨테이너 클래스명 */
className?: string; className?: string;
/** 마우스 인터랙션 설정 */
mouseInteraction?: MouseInteractionConfig;
} }
/** /**
* GPU * GPU
* Three.js와 GLSL . * Three.js와 GLSL .
*/ */
declare const ImageDistortion: React.FC<ImageDistortionProps>; declare const ImageDistortion: React$1.FC<ImageDistortionProps>;
/** /**
* *
@ -248,7 +309,7 @@ interface DistortionEditorProps {
canvasStyle?: EditorCanvasStyle; canvasStyle?: EditorCanvasStyle;
} }
declare const DistortionEditor: React.FC<DistortionEditorProps>; declare const DistortionEditor: React$1.FC<DistortionEditorProps>;
declare const useDistortionEditor: (initialAreas?: DistortionArea[]) => { declare const useDistortionEditor: (initialAreas?: DistortionArea[]) => {
state: EditorState; state: EditorState;
@ -403,6 +464,57 @@ declare class AnimationLoop {
static updateProgress(areas: DistortionArea[], deltaTime: number): DistortionArea[]; static updateProgress(areas: DistortionArea[], deltaTime: number): DistortionArea[];
} }
/**
*
* Hooke's Law와 -
*/
declare class SpringPhysics {
private config;
private state;
constructor(config: SpringPhysicsConfig);
/**
*
*/
setConfig(config: Partial<SpringPhysicsConfig>): void;
/**
* ( )
*/
setTarget(velocity: Point, velocityMultiplier?: number): void;
/**
* ( )
*
*/
setInitialVelocity(velocity: Point, multiplier?: number): void;
/**
* (Hooke's Law + Damping)
* F = -k * x - c * v
* a = F / m
* v += a * dt
* x += v * dt
*/
update(deltaTime: number): Point;
/**
* ( )
*/
applyImpulse(acceleration: Point, multiplier?: number): void;
/**
*
*/
getDisplacement(): Point;
/**
*
*/
getVelocity(): Point;
/**
*
*/
reset(): void;
/**
* 0 ( )
*/
returnToEquilibrium(): void;
}
/** /**
* requestAnimationFrame을 * requestAnimationFrame을
* @param callback (deltaTime을 ) * @param callback (deltaTime을 )
@ -410,4 +522,21 @@ declare class AnimationLoop {
*/ */
declare const useAnimationFrame: (callback: (deltaTime: number) => void, isPlaying?: boolean) => void; declare const useAnimationFrame: (callback: (deltaTime: number) => void, isPlaying?: boolean) => void;
export { ANIMATION_CONFIG, AnimationLoop, type AnimationState, type AnimationTicker, type AreaBounds, DEFAULT_AREA, type DistortionArea, DistortionEditor, type DistortionEditorProps, type DistortionMovement, type EasingFunction, type EditMode, type EditorState, ImageDistortion, type ImageDistortionProps, type Point, SHADER_CONFIG, type ShaderConfig, ShaderManager, type ShaderUniforms, ThreeScene, applyEasing, useAnimationFrame, useDistortionEditor }; /**
* , ,
*/
declare const useMouseVelocity: (containerRef: React.RefObject<HTMLElement | null>) => {
getState: () => MouseState;
};
/**
*
*
*/
declare const useMouseInteraction: (containerRef: React.RefObject<HTMLElement | null>, config: MouseInteractionConfig) => {
updateInteraction: (areas: DistortionArea[], deltaTime: number) => DistortionArea[];
updateConfig: (newConfig: Partial<MouseInteractionConfig>) => void;
reset: () => void;
};
export { ANIMATION_CONFIG, AnimationLoop, type AnimationState, type AnimationTicker, type AreaBounds, DEFAULT_AREA, type DistortionArea, DistortionEditor, type DistortionEditorProps, type DistortionMovement, type EasingFunction, type EditMode, type EditorState, ImageDistortion, type ImageDistortionProps, type MouseInteractionConfig, type MouseState, type Point, SHADER_CONFIG, type ShaderConfig, ShaderManager, type ShaderUniforms, SpringPhysics, type SpringPhysicsConfig, type SpringState, ThreeScene, applyEasing, useAnimationFrame, useDistortionEditor, useMouseInteraction, useMouseVelocity };

504
dist/index.js vendored
View File

@ -37,15 +37,18 @@ __export(index_exports, {
ImageDistortion: () => ImageDistortion, ImageDistortion: () => ImageDistortion,
SHADER_CONFIG: () => SHADER_CONFIG, SHADER_CONFIG: () => SHADER_CONFIG,
ShaderManager: () => ShaderManager, ShaderManager: () => ShaderManager,
SpringPhysics: () => SpringPhysics,
ThreeScene: () => ThreeScene, ThreeScene: () => ThreeScene,
applyEasing: () => applyEasing, applyEasing: () => applyEasing,
useAnimationFrame: () => useAnimationFrame, useAnimationFrame: () => useAnimationFrame,
useDistortionEditor: () => useDistortionEditor useDistortionEditor: () => useDistortionEditor,
useMouseInteraction: () => useMouseInteraction,
useMouseVelocity: () => useMouseVelocity
}); });
module.exports = __toCommonJS(index_exports); module.exports = __toCommonJS(index_exports);
// src/components/ImageDistortion.tsx // src/components/ImageDistortion.tsx
var import_react2 = require("react"); var import_react4 = require("react");
var THREE2 = __toESM(require("three")); var THREE2 = __toESM(require("three"));
// src/engine/ThreeScene.ts // src/engine/ThreeScene.ts
@ -134,7 +137,6 @@ var ThreeScene = class {
* 렌더링 * 렌더링
*/ */
render() { render() {
console.log("[ThreeScene] render() \uD638\uCD9C\uB428, mesh:", this.mesh);
this.renderer.render(this.scene, this.camera); this.renderer.render(this.scene, this.camera);
} }
/** /**
@ -252,6 +254,12 @@ var AnimationLoop = class {
static updateAreaDragVectors(areas) { static updateAreaDragVectors(areas) {
return areas.map((area) => { return areas.map((area) => {
const { progress, movement } = area; const { progress, movement } = area;
if (movement.duration <= 0) {
return {
...area,
dragVector: { x: 0, y: 0 }
};
}
const easedProgress = applyEasing(progress, movement.easing); const easedProgress = applyEasing(progress, movement.easing);
let dragVector; let dragVector;
if (easedProgress < 0.5) { if (easedProgress < 0.5) {
@ -281,6 +289,9 @@ var AnimationLoop = class {
*/ */
static updateProgress(areas, deltaTime) { static updateProgress(areas, deltaTime) {
return areas.map((area) => { return areas.map((area) => {
if (area.movement.duration <= 0) {
return area;
}
let newProgress = area.progress + deltaTime / area.movement.duration; let newProgress = area.progress + deltaTime / area.movement.duration;
newProgress %= 1; newProgress %= 1;
return { return {
@ -315,6 +326,369 @@ var useAnimationFrame = (callback, isPlaying = true) => {
}, [callback, isPlaying]); }, [callback, isPlaying]);
}; };
// src/hooks/useMouseInteraction.ts
var import_react3 = require("react");
// src/hooks/useMouseVelocity.ts
var import_react2 = require("react");
var useMouseVelocity = (containerRef) => {
const mouseStateRef = (0, import_react2.useRef)({
position: null,
prevPosition: null,
velocity: { x: 0, y: 0 },
acceleration: { x: 0, y: 0 },
isHovering: false,
isDragging: false
});
const lastUpdateTimeRef = (0, import_react2.useRef)(Date.now());
const prevVelocityRef = (0, import_react2.useRef)({ x: 0, y: 0 });
const toNormalized = (0, import_react2.useCallback)((clientX, clientY) => {
if (!containerRef.current) return null;
const rect = containerRef.current.getBoundingClientRect();
return {
x: (clientX - rect.left) / rect.width,
y: (clientY - rect.top) / rect.height
};
}, [containerRef]);
const updatePosition = (0, import_react2.useCallback)((clientX, clientY) => {
const now = Date.now();
const deltaTime = (now - lastUpdateTimeRef.current) / 1e3;
lastUpdateTimeRef.current = now;
const normalizedPos = toNormalized(clientX, clientY);
if (!normalizedPos) return;
const state = mouseStateRef.current;
const prevPos = state.position;
let velocity = { x: 0, y: 0 };
if (prevPos && deltaTime > 0) {
velocity = {
x: (normalizedPos.x - prevPos.x) / deltaTime,
y: (normalizedPos.y - prevPos.y) / deltaTime
};
}
const prevVel = prevVelocityRef.current;
let acceleration = { x: 0, y: 0 };
if (deltaTime > 0) {
acceleration = {
x: (velocity.x - prevVel.x) / deltaTime,
y: (velocity.y - prevVel.y) / deltaTime
};
}
mouseStateRef.current = {
position: normalizedPos,
prevPosition: prevPos,
velocity,
acceleration,
isHovering: true,
isDragging: state.isDragging
};
prevVelocityRef.current = velocity;
}, [toNormalized]);
const handleMouseMove = (0, import_react2.useCallback)((e) => {
updatePosition(e.clientX, e.clientY);
}, [updatePosition]);
const handleMouseEnter = (0, import_react2.useCallback)(() => {
mouseStateRef.current.isHovering = true;
}, []);
const handleMouseLeave = (0, import_react2.useCallback)(() => {
mouseStateRef.current = {
position: null,
prevPosition: null,
velocity: { x: 0, y: 0 },
acceleration: { x: 0, y: 0 },
isHovering: false,
isDragging: false
};
prevVelocityRef.current = { x: 0, y: 0 };
}, []);
const handleMouseDown = (0, import_react2.useCallback)(() => {
mouseStateRef.current.isDragging = true;
}, []);
const handleMouseUp = (0, import_react2.useCallback)(() => {
mouseStateRef.current.isDragging = false;
}, []);
const handleTouchMove = (0, import_react2.useCallback)((e) => {
if (e.touches.length > 0) {
const touch = e.touches[0];
updatePosition(touch.clientX, touch.clientY);
}
}, [updatePosition]);
const handleTouchStart = (0, import_react2.useCallback)((e) => {
mouseStateRef.current.isDragging = true;
mouseStateRef.current.isHovering = true;
if (e.touches.length > 0) {
const touch = e.touches[0];
updatePosition(touch.clientX, touch.clientY);
}
}, [updatePosition]);
const handleTouchEnd = (0, import_react2.useCallback)(() => {
mouseStateRef.current.isDragging = false;
mouseStateRef.current.isHovering = false;
mouseStateRef.current.position = null;
mouseStateRef.current.prevPosition = null;
mouseStateRef.current.velocity = { x: 0, y: 0 };
mouseStateRef.current.acceleration = { x: 0, y: 0 };
prevVelocityRef.current = { x: 0, y: 0 };
}, []);
(0, import_react2.useEffect)(() => {
const container = containerRef.current;
if (!container) return;
container.addEventListener("mousemove", handleMouseMove);
container.addEventListener("mouseenter", handleMouseEnter);
container.addEventListener("mouseleave", handleMouseLeave);
container.addEventListener("mousedown", handleMouseDown);
window.addEventListener("mouseup", handleMouseUp);
container.addEventListener("touchmove", handleTouchMove, { passive: true });
container.addEventListener("touchstart", handleTouchStart, { passive: true });
container.addEventListener("touchend", handleTouchEnd);
container.addEventListener("touchcancel", handleTouchEnd);
return () => {
container.removeEventListener("mousemove", handleMouseMove);
container.removeEventListener("mouseenter", handleMouseEnter);
container.removeEventListener("mouseleave", handleMouseLeave);
container.removeEventListener("mousedown", handleMouseDown);
window.removeEventListener("mouseup", handleMouseUp);
container.removeEventListener("touchmove", handleTouchMove);
container.removeEventListener("touchstart", handleTouchStart);
container.removeEventListener("touchend", handleTouchEnd);
container.removeEventListener("touchcancel", handleTouchEnd);
};
}, [containerRef, handleMouseMove, handleMouseEnter, handleMouseLeave, handleMouseDown, handleMouseUp, handleTouchMove, handleTouchStart, handleTouchEnd]);
const getState = (0, import_react2.useCallback)(() => {
return { ...mouseStateRef.current };
}, []);
return {
getState
};
};
// src/engine/SpringPhysics.ts
var SpringPhysics = class {
constructor(config) {
this.config = config;
this.state = {
displacement: { x: 0, y: 0 },
velocity: { x: 0, y: 0 },
target: { x: 0, y: 0 }
};
}
/**
* 물리 파라미터 업데이트
*/
setConfig(config) {
this.config = { ...this.config, ...config };
}
/**
* 목표 위치 설정 (마우스 속도 기반)
*/
setTarget(velocity, velocityMultiplier = 1) {
this.state.target = {
x: velocity.x * velocityMultiplier,
y: velocity.y * velocityMultiplier
};
}
/**
* 초기 속도 설정 (드래그 방향과 속도를 즉시 반영)
* 드래그 방향으로 즉시 튕기는 효과
*/
setInitialVelocity(velocity, multiplier = 1) {
this.state.velocity = {
x: velocity.x * multiplier,
y: velocity.y * multiplier
};
this.state.target = { x: 0, y: 0 };
}
/**
* 스프링 물리 업데이트 (Hooke's Law + Damping)
* F = -k * x - c * v
* a = F / m
* v += a * dt
* x += v * dt
*/
update(deltaTime) {
const { stiffness, damping, mass } = this.config;
const { displacement, velocity, target } = this.state;
const dx = displacement.x - target.x;
const dy = displacement.y - target.y;
const springForceX = -stiffness * dx;
const springForceY = -stiffness * dy;
const dampingForceX = -damping * velocity.x;
const dampingForceY = -damping * velocity.y;
const totalForceX = springForceX + dampingForceX;
const totalForceY = springForceY + dampingForceY;
const accelerationX = totalForceX / mass;
const accelerationY = totalForceY / mass;
const newVelocityX = velocity.x + accelerationX * deltaTime;
const newVelocityY = velocity.y + accelerationY * deltaTime;
const newDisplacementX = displacement.x + newVelocityX * deltaTime;
const newDisplacementY = displacement.y + newVelocityY * deltaTime;
this.state = {
displacement: { x: newDisplacementX, y: newDisplacementY },
velocity: { x: newVelocityX, y: newVelocityY },
target
};
const isNearlyZero = (val) => Math.abs(val) < 1e-4;
if (isNearlyZero(this.state.displacement.x) && isNearlyZero(this.state.displacement.y) && isNearlyZero(this.state.velocity.x) && isNearlyZero(this.state.velocity.y)) {
this.reset();
}
return this.state.displacement;
}
/**
* 즉시 충격 적용 (마우스 가속도 기반)
*/
applyImpulse(acceleration, multiplier = 1) {
this.state.velocity.x += acceleration.x * multiplier;
this.state.velocity.y += acceleration.y * multiplier;
}
/**
* 현재 변위 가져오기
*/
getDisplacement() {
return { ...this.state.displacement };
}
/**
* 현재 속도 가져오기
*/
getVelocity() {
return { ...this.state.velocity };
}
/**
* 상태 리셋
*/
reset() {
this.state = {
displacement: { x: 0, y: 0 },
velocity: { x: 0, y: 0 },
target: { x: 0, y: 0 }
};
}
/**
* 마우스가 멈췄을 목표를 0으로 설정 (평형 상태로 복귀)
*/
returnToEquilibrium() {
this.state.target = { x: 0, y: 0 };
}
};
// src/hooks/useMouseInteraction.ts
var isPointInPolygon = (point, polygon) => {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].x, yi = polygon[i].y;
const xj = polygon[j].x, yj = polygon[j].y;
const intersect = yi > point.y !== yj > point.y && point.x < (xj - xi) * (point.y - yi) / (yj - yi) + xi;
if (intersect) inside = !inside;
}
return inside;
};
var useMouseInteraction = (containerRef, config) => {
const { getState } = useMouseVelocity(containerRef);
const [interactingAreaIndex, setInteractingAreaIndex] = (0, import_react3.useState)(null);
const springPhysicsMapRef = (0, import_react3.useRef)(/* @__PURE__ */ new Map());
const getSpringPhysics = (0, import_react3.useCallback)((areaIndex) => {
if (!springPhysicsMapRef.current.has(areaIndex)) {
springPhysicsMapRef.current.set(areaIndex, new SpringPhysics(config.physics));
}
return springPhysicsMapRef.current.get(areaIndex);
}, [config.physics]);
const updateInteraction = (0, import_react3.useCallback)((areas, deltaTime) => {
if (!config.enabled) return areas;
const mouseState = getState();
if (mouseState.isDragging && mouseState.position) {
if (interactingAreaIndex === null) {
for (let i = areas.length - 1; i >= 0; i--) {
if (isPointInPolygon(mouseState.position, areas[i].basePoints)) {
setInteractingAreaIndex(i);
getSpringPhysics(i).reset();
break;
}
}
}
if (interactingAreaIndex !== null) {
const velocityMult = config.velocityMultiplier || 1;
const spring = getSpringPhysics(interactingAreaIndex);
const velocityMag = Math.sqrt(
mouseState.velocity.x ** 2 + mouseState.velocity.y ** 2
);
const minVel = config.minVelocity || 0.05;
const maxVel = config.maxVelocity || 5;
if (velocityMag >= minVel) {
let clampedVelocity = mouseState.velocity;
if (velocityMag > maxVel) {
const scale = maxVel / velocityMag;
clampedVelocity = {
x: mouseState.velocity.x * scale,
y: mouseState.velocity.y * scale
};
}
spring.setTarget(clampedVelocity, velocityMult);
} else {
spring.returnToEquilibrium();
}
}
} else {
if (interactingAreaIndex !== null) {
const velocityMult = config.velocityMultiplier || 1;
const spring = getSpringPhysics(interactingAreaIndex);
const maxVel = config.maxVelocity || 5;
const velocityMag = Math.sqrt(
mouseState.velocity.x ** 2 + mouseState.velocity.y ** 2
);
let clampedVelocity = mouseState.velocity;
if (velocityMag > maxVel) {
const scale = maxVel / velocityMag;
clampedVelocity = {
x: mouseState.velocity.x * scale,
y: mouseState.velocity.y * scale
};
}
spring.setInitialVelocity(clampedVelocity, velocityMult);
setInteractingAreaIndex(null);
}
}
return areas.map((area, index) => {
const spring = springPhysicsMapRef.current.get(index);
if (!spring) return area;
const springVelocity = spring.getVelocity();
const springDisplacement = spring.getDisplacement();
const isSpringActive = Math.sqrt(springVelocity.x ** 2 + springVelocity.y ** 2) > 1e-3 || Math.sqrt(springDisplacement.x ** 2 + springDisplacement.y ** 2) > 1e-3;
if (index !== interactingAreaIndex && !isSpringActive) {
return area;
}
const displacement = spring.update(deltaTime);
const displacementMag = Math.sqrt(displacement.x ** 2 + displacement.y ** 2);
if (displacementMag < 1e-3) {
return area;
}
return {
...area,
dragVector: {
x: area.dragVector.x - displacement.x,
y: area.dragVector.y - displacement.y
}
};
});
}, [config, getState, interactingAreaIndex, getSpringPhysics]);
const updateConfig = (0, import_react3.useCallback)((newConfig) => {
const physicsConfig = newConfig.physics;
if (physicsConfig) {
springPhysicsMapRef.current.forEach((spring) => {
spring.setConfig(physicsConfig);
});
}
}, []);
const reset = (0, import_react3.useCallback)(() => {
springPhysicsMapRef.current.forEach((spring) => {
spring.reset();
});
setInteractingAreaIndex(null);
}, []);
return {
updateInteraction,
updateConfig,
reset
};
};
// src/utils/constants.ts // src/utils/constants.ts
var SHADER_CONFIG = { var SHADER_CONFIG = {
/** 최대 영역 개수 */ /** 최대 영역 개수 */
@ -354,19 +728,38 @@ var ImageDistortion = ({
fragmentShaderPath, fragmentShaderPath,
isPlaying = true, isPlaying = true,
style, style,
className className,
mouseInteraction
}) => { }) => {
const containerRef = (0, import_react2.useRef)(null); const containerRef = (0, import_react4.useRef)(null);
const sceneRef = (0, import_react2.useRef)(null); const sceneRef = (0, import_react4.useRef)(null);
const shaderManagerRef = (0, import_react2.useRef)(new ShaderManager()); const shaderManagerRef = (0, import_react4.useRef)(new ShaderManager());
const textureRef = (0, import_react2.useRef)(null); const textureRef = (0, import_react4.useRef)(null);
const [isReady, setIsReady] = (0, import_react2.useState)(false); const [isReady, setIsReady] = (0, import_react4.useState)(false);
const [imageLoaded, setImageLoaded] = (0, import_react2.useState)(false); const [imageLoaded, setImageLoaded] = (0, import_react4.useState)(false);
const [currentAreas, setCurrentAreas] = (0, import_react2.useState)(areas); const [currentAreas, setCurrentAreas] = (0, import_react4.useState)(areas);
(0, import_react2.useEffect)(() => { const mouseInteractionHook = useMouseInteraction(
containerRef,
mouseInteraction || {
enabled: false,
physics: {
stiffness: 100,
damping: 10,
mass: 1,
influenceRadius: 0.2,
maxStrength: 1
}
}
);
(0, import_react4.useEffect)(() => {
setCurrentAreas(areas); setCurrentAreas(areas);
}, [areas]); }, [areas]);
(0, import_react2.useEffect)(() => { (0, import_react4.useEffect)(() => {
if (mouseInteraction) {
mouseInteractionHook.updateConfig(mouseInteraction);
}
}, [mouseInteraction, mouseInteractionHook]);
(0, import_react4.useEffect)(() => {
console.log("[ImageDistortion] useEffect \uC2E4\uD589, containerRef.current:", containerRef.current); console.log("[ImageDistortion] useEffect \uC2E4\uD589, containerRef.current:", containerRef.current);
if (!containerRef.current) { if (!containerRef.current) {
console.warn("[ImageDistortion] containerRef.current\uAC00 null\uC785\uB2C8\uB2E4. \uCEF4\uD3EC\uB10C\uD2B8\uAC00 \uC81C\uB300\uB85C \uB9C8\uC6B4\uD2B8\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4."); console.warn("[ImageDistortion] containerRef.current\uAC00 null\uC785\uB2C8\uB2E4. \uCEF4\uD3EC\uB10C\uD2B8\uAC00 \uC81C\uB300\uB85C \uB9C8\uC6B4\uD2B8\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4.");
@ -392,7 +785,7 @@ var ImageDistortion = ({
} }
}; };
}, [vertexShaderPath, fragmentShaderPath]); }, [vertexShaderPath, fragmentShaderPath]);
(0, import_react2.useEffect)(() => { (0, import_react4.useEffect)(() => {
if (!imageSrc || !isReady) { if (!imageSrc || !isReady) {
console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2A4\uD0B5:", { imageSrc, isReady }); console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2A4\uD0B5:", { imageSrc, isReady });
return; return;
@ -435,7 +828,7 @@ var ImageDistortion = ({
} }
}; };
}, [imageSrc, isReady]); }, [imageSrc, isReady]);
(0, import_react2.useEffect)(() => { (0, import_react4.useEffect)(() => {
if (!sceneRef.current || !isReady) return; if (!sceneRef.current || !isReady) return;
const resolution = sceneRef.current.getResolution(); const resolution = sceneRef.current.getResolution();
const points = new Float32Array(SHADER_CONFIG.MAX_POINTS * 2); const points = new Float32Array(SHADER_CONFIG.MAX_POINTS * 2);
@ -464,14 +857,18 @@ var ImageDistortion = ({
}); });
sceneRef.current.render(); sceneRef.current.render();
}, [currentAreas, isReady]); }, [currentAreas, isReady]);
const animationCallback = (0, import_react2.useCallback)((deltaTime) => { const animationCallback = (0, import_react4.useCallback)((deltaTime) => {
if (!isReady) return; if (!isReady) return;
setCurrentAreas((prevAreas) => { setCurrentAreas((prevAreas) => {
const updatedAreas = AnimationLoop.updateProgress(prevAreas, deltaTime); let updatedAreas = AnimationLoop.updateProgress(prevAreas, deltaTime);
return AnimationLoop.updateAreaDragVectors(updatedAreas); updatedAreas = AnimationLoop.updateAreaDragVectors(updatedAreas);
if (mouseInteraction?.enabled) {
updatedAreas = mouseInteractionHook.updateInteraction(updatedAreas, deltaTime);
}
return updatedAreas;
}); });
}, [isReady]); }, [isReady, mouseInteraction, mouseInteractionHook]);
useAnimationFrame(animationCallback, isPlaying); useAnimationFrame(animationCallback, isPlaying || mouseInteraction?.enabled || false);
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)( return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
"div", "div",
{ {
@ -505,28 +902,28 @@ var ImageDistortion = ({
}; };
// src/editor/DistortionEditor.tsx // src/editor/DistortionEditor.tsx
var import_react5 = require("react"); var import_react7 = require("react");
// src/editor/hooks/useDistortionEditor.ts // src/editor/hooks/useDistortionEditor.ts
var import_react3 = require("react"); var import_react5 = require("react");
var useDistortionEditor = (initialAreas = []) => { var useDistortionEditor = (initialAreas = []) => {
const [state, setState] = (0, import_react3.useState)({ const [state, setState] = (0, import_react5.useState)({
selectedAreaId: initialAreas[0]?.id || null, selectedAreaId: initialAreas[0]?.id || null,
areas: initialAreas, areas: initialAreas,
editMode: "normal", editMode: "normal",
draggingPointIndex: null draggingPointIndex: null
}); });
const selectArea = (0, import_react3.useCallback)((areaId) => { const selectArea = (0, import_react5.useCallback)((areaId) => {
setState((prev) => ({ ...prev, selectedAreaId: areaId })); setState((prev) => ({ ...prev, selectedAreaId: areaId }));
}, []); }, []);
const addArea = (0, import_react3.useCallback)((area) => { const addArea = (0, import_react5.useCallback)((area) => {
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
areas: [...prev.areas, area], areas: [...prev.areas, area],
selectedAreaId: area.id selectedAreaId: area.id
})); }));
}, []); }, []);
const removeArea = (0, import_react3.useCallback)((areaId) => { const removeArea = (0, import_react5.useCallback)((areaId) => {
setState((prev) => { setState((prev) => {
const newAreas = prev.areas.filter((a) => a.id !== areaId); const newAreas = prev.areas.filter((a) => a.id !== areaId);
return { return {
@ -536,13 +933,13 @@ var useDistortionEditor = (initialAreas = []) => {
}; };
}); });
}, []); }, []);
const updateArea = (0, import_react3.useCallback)((areaId, updates) => { const updateArea = (0, import_react5.useCallback)((areaId, updates) => {
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
areas: prev.areas.map((area) => area.id === areaId ? { ...area, ...updates } : area) areas: prev.areas.map((area) => area.id === areaId ? { ...area, ...updates } : area)
})); }));
}, []); }, []);
const updatePoint = (0, import_react3.useCallback)((areaId, pointIndex, point) => { const updatePoint = (0, import_react5.useCallback)((areaId, pointIndex, point) => {
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
areas: prev.areas.map((area) => { areas: prev.areas.map((area) => {
@ -555,16 +952,16 @@ var useDistortionEditor = (initialAreas = []) => {
}) })
})); }));
}, []); }, []);
const startDragging = (0, import_react3.useCallback)((pointIndex) => { const startDragging = (0, import_react5.useCallback)((pointIndex) => {
setState((prev) => ({ ...prev, draggingPointIndex: pointIndex })); setState((prev) => ({ ...prev, draggingPointIndex: pointIndex }));
}, []); }, []);
const stopDragging = (0, import_react3.useCallback)(() => { const stopDragging = (0, import_react5.useCallback)(() => {
setState((prev) => ({ ...prev, draggingPointIndex: null })); setState((prev) => ({ ...prev, draggingPointIndex: null }));
}, []); }, []);
const setEditMode = (0, import_react3.useCallback)((mode) => { const setEditMode = (0, import_react5.useCallback)((mode) => {
setState((prev) => ({ ...prev, editMode: mode })); setState((prev) => ({ ...prev, editMode: mode }));
}, []); }, []);
const getSelectedArea = (0, import_react3.useCallback)(() => { const getSelectedArea = (0, import_react5.useCallback)(() => {
return state.areas.find((a) => a.id === state.selectedAreaId) || null; return state.areas.find((a) => a.id === state.selectedAreaId) || null;
}, [state.areas, state.selectedAreaId]); }, [state.areas, state.selectedAreaId]);
return { return {
@ -582,7 +979,7 @@ var useDistortionEditor = (initialAreas = []) => {
}; };
// src/editor/components/EditorCanvas.tsx // src/editor/components/EditorCanvas.tsx
var import_react4 = require("react"); var import_react6 = require("react");
// src/editor/constants.ts // src/editor/constants.ts
var DEFAULT_EDITOR_CANVAS_STYLE = { var DEFAULT_EDITOR_CANVAS_STYLE = {
@ -658,11 +1055,11 @@ var EditorCanvas = ({
style: customStyle, style: customStyle,
showEditor = true showEditor = true
}) => { }) => {
const containerRef = (0, import_react4.useRef)(null); const containerRef = (0, import_react6.useRef)(null);
const [canvasSize, setCanvasSize] = (0, import_react4.useState)({ width: 0, height: 0 }); const [canvasSize, setCanvasSize] = (0, import_react6.useState)({ width: 0, height: 0 });
const [isDraggingArea, setIsDraggingArea] = (0, import_react4.useState)(false); const [isDraggingArea, setIsDraggingArea] = (0, import_react6.useState)(false);
const [dragStartPos, setDragStartPos] = (0, import_react4.useState)(null); const [dragStartPos, setDragStartPos] = (0, import_react6.useState)(null);
const editorStyle = (0, import_react4.useMemo)(() => ({ const editorStyle = (0, import_react6.useMemo)(() => ({
...DEFAULT_EDITOR_CANVAS_STYLE, ...DEFAULT_EDITOR_CANVAS_STYLE,
...customStyle, ...customStyle,
circleLevels: customStyle?.circleLevels || DEFAULT_EDITOR_CANVAS_STYLE.circleLevels, circleLevels: customStyle?.circleLevels || DEFAULT_EDITOR_CANVAS_STYLE.circleLevels,
@ -679,13 +1076,13 @@ var EditorCanvas = ({
...customStyle?.areaOutline ...customStyle?.areaOutline
} }
}), [customStyle]); }), [customStyle]);
(0, import_react4.useEffect)(() => { (0, import_react6.useEffect)(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect(); const rect = containerRef.current.getBoundingClientRect();
setCanvasSize({ width: rect.width, height: rect.height }); setCanvasSize({ width: rect.width, height: rect.height });
}, [width, height]); }, [width, height]);
const selectedArea = areas.find((a) => a.id === selectedAreaId); const selectedArea = areas.find((a) => a.id === selectedAreaId);
const isPointInPolygon = (0, import_react4.useCallback)((point, polygon) => { const isPointInPolygon2 = (0, import_react6.useCallback)((point, polygon) => {
let inside = false; let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].x, yi = polygon[i].y; const xi = polygon[i].x, yi = polygon[i].y;
@ -695,7 +1092,7 @@ var EditorCanvas = ({
} }
return inside; return inside;
}, []); }, []);
const handleMouseDown = (0, import_react4.useCallback)( const handleMouseDown = (0, import_react6.useCallback)(
(pointIndex) => (e) => { (pointIndex) => (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -703,22 +1100,22 @@ var EditorCanvas = ({
}, },
[onStartDragging] [onStartDragging]
); );
const handleCanvasMouseDown = (0, import_react4.useCallback)( const handleCanvasMouseDown = (0, import_react6.useCallback)(
(e) => { (e) => {
if (!showEditor || !selectedArea || !containerRef.current) return; if (!showEditor || !selectedArea || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect(); const rect = containerRef.current.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width; const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height; const y = (e.clientY - rect.top) / rect.height;
const clickPoint = { x, y }; const clickPoint = { x, y };
if (isPointInPolygon(clickPoint, selectedArea.basePoints)) { if (isPointInPolygon2(clickPoint, selectedArea.basePoints)) {
setIsDraggingArea(true); setIsDraggingArea(true);
setDragStartPos(clickPoint); setDragStartPos(clickPoint);
e.preventDefault(); e.preventDefault();
} }
}, },
[showEditor, selectedArea, isPointInPolygon] [showEditor, selectedArea, isPointInPolygon2]
); );
const handleMouseMove = (0, import_react4.useCallback)( const handleMouseMove = (0, import_react6.useCallback)(
(e) => { (e) => {
if (!showEditor || !selectedArea || !containerRef.current) return; if (!showEditor || !selectedArea || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect(); const rect = containerRef.current.getBoundingClientRect();
@ -741,7 +1138,7 @@ var EditorCanvas = ({
}, },
[showEditor, draggingPointIndex, isDraggingArea, dragStartPos, selectedArea, onUpdatePoint, onUpdateArea] [showEditor, draggingPointIndex, isDraggingArea, dragStartPos, selectedArea, onUpdatePoint, onUpdateArea]
); );
const handleMouseUp = (0, import_react4.useCallback)(() => { const handleMouseUp = (0, import_react6.useCallback)(() => {
if (draggingPointIndex !== null) { if (draggingPointIndex !== null) {
onStopDragging(); onStopDragging();
} }
@ -750,7 +1147,7 @@ var EditorCanvas = ({
setDragStartPos(null); setDragStartPos(null);
} }
}, [draggingPointIndex, isDraggingArea, onStopDragging]); }, [draggingPointIndex, isDraggingArea, onStopDragging]);
(0, import_react4.useEffect)(() => { (0, import_react6.useEffect)(() => {
if (draggingPointIndex !== null || isDraggingArea) { if (draggingPointIndex !== null || isDraggingArea) {
window.addEventListener("mouseup", handleMouseUp); window.addEventListener("mouseup", handleMouseUp);
return () => window.removeEventListener("mouseup", handleMouseUp); return () => window.removeEventListener("mouseup", handleMouseUp);
@ -769,7 +1166,7 @@ var EditorCanvas = ({
y: posY * canvasHeight y: posY * canvasHeight
}; };
}; };
const drawDistortionCircle = (0, import_react4.useCallback)((ctx, points, canvasWidth, canvasHeight) => { const drawDistortionCircle = (0, import_react6.useCallback)((ctx, points, canvasWidth, canvasHeight) => {
const segments = 128; const segments = 128;
const centerU = 0.5; const centerU = 0.5;
const centerV = 0.5; const centerV = 0.5;
@ -1157,11 +1554,11 @@ var DistortionEditor = ({
stopDragging, stopDragging,
getSelectedArea getSelectedArea
} = useDistortionEditor(initialAreas); } = useDistortionEditor(initialAreas);
const [showEditor, setShowEditor] = (0, import_react5.useState)(true); const [showEditor, setShowEditor] = (0, import_react7.useState)(true);
(0, import_react5.useEffect)(() => { (0, import_react7.useEffect)(() => {
onAreasChange?.(state.areas); onAreasChange?.(state.areas);
}, [state.areas, onAreasChange]); }, [state.areas, onAreasChange]);
(0, import_react5.useEffect)(() => { (0, import_react7.useEffect)(() => {
onSelectedAreaChange?.(state.selectedAreaId); onSelectedAreaChange?.(state.selectedAreaId);
}, [state.selectedAreaId, onSelectedAreaChange]); }, [state.selectedAreaId, onSelectedAreaChange]);
const handleAddArea = () => { const handleAddArea = () => {
@ -1244,9 +1641,12 @@ var DistortionEditor = ({
ImageDistortion, ImageDistortion,
SHADER_CONFIG, SHADER_CONFIG,
ShaderManager, ShaderManager,
SpringPhysics,
ThreeScene, ThreeScene,
applyEasing, applyEasing,
useAnimationFrame, useAnimationFrame,
useDistortionEditor useDistortionEditor,
useMouseInteraction,
useMouseVelocity
}); });
//# sourceMappingURL=index.js.map //# sourceMappingURL=index.js.map

2
dist/index.js.map vendored

File diff suppressed because one or more lines are too long

497
dist/index.mjs vendored
View File

@ -1,5 +1,5 @@
// src/components/ImageDistortion.tsx // src/components/ImageDistortion.tsx
import { useEffect as useEffect2, useRef as useRef2, useState, useCallback } from "react"; import { useEffect as useEffect3, useRef as useRef4, useState as useState2, useCallback as useCallback3 } from "react";
import * as THREE2 from "three"; import * as THREE2 from "three";
// src/engine/ThreeScene.ts // src/engine/ThreeScene.ts
@ -88,7 +88,6 @@ var ThreeScene = class {
* 렌더링 * 렌더링
*/ */
render() { render() {
console.log("[ThreeScene] render() \uD638\uCD9C\uB428, mesh:", this.mesh);
this.renderer.render(this.scene, this.camera); this.renderer.render(this.scene, this.camera);
} }
/** /**
@ -206,6 +205,12 @@ var AnimationLoop = class {
static updateAreaDragVectors(areas) { static updateAreaDragVectors(areas) {
return areas.map((area) => { return areas.map((area) => {
const { progress, movement } = area; const { progress, movement } = area;
if (movement.duration <= 0) {
return {
...area,
dragVector: { x: 0, y: 0 }
};
}
const easedProgress = applyEasing(progress, movement.easing); const easedProgress = applyEasing(progress, movement.easing);
let dragVector; let dragVector;
if (easedProgress < 0.5) { if (easedProgress < 0.5) {
@ -235,6 +240,9 @@ var AnimationLoop = class {
*/ */
static updateProgress(areas, deltaTime) { static updateProgress(areas, deltaTime) {
return areas.map((area) => { return areas.map((area) => {
if (area.movement.duration <= 0) {
return area;
}
let newProgress = area.progress + deltaTime / area.movement.duration; let newProgress = area.progress + deltaTime / area.movement.duration;
newProgress %= 1; newProgress %= 1;
return { return {
@ -269,6 +277,369 @@ var useAnimationFrame = (callback, isPlaying = true) => {
}, [callback, isPlaying]); }, [callback, isPlaying]);
}; };
// src/hooks/useMouseInteraction.ts
import { useRef as useRef3, useCallback as useCallback2, useState } from "react";
// src/hooks/useMouseVelocity.ts
import { useRef as useRef2, useCallback, useEffect as useEffect2 } from "react";
var useMouseVelocity = (containerRef) => {
const mouseStateRef = useRef2({
position: null,
prevPosition: null,
velocity: { x: 0, y: 0 },
acceleration: { x: 0, y: 0 },
isHovering: false,
isDragging: false
});
const lastUpdateTimeRef = useRef2(Date.now());
const prevVelocityRef = useRef2({ x: 0, y: 0 });
const toNormalized = useCallback((clientX, clientY) => {
if (!containerRef.current) return null;
const rect = containerRef.current.getBoundingClientRect();
return {
x: (clientX - rect.left) / rect.width,
y: (clientY - rect.top) / rect.height
};
}, [containerRef]);
const updatePosition = useCallback((clientX, clientY) => {
const now = Date.now();
const deltaTime = (now - lastUpdateTimeRef.current) / 1e3;
lastUpdateTimeRef.current = now;
const normalizedPos = toNormalized(clientX, clientY);
if (!normalizedPos) return;
const state = mouseStateRef.current;
const prevPos = state.position;
let velocity = { x: 0, y: 0 };
if (prevPos && deltaTime > 0) {
velocity = {
x: (normalizedPos.x - prevPos.x) / deltaTime,
y: (normalizedPos.y - prevPos.y) / deltaTime
};
}
const prevVel = prevVelocityRef.current;
let acceleration = { x: 0, y: 0 };
if (deltaTime > 0) {
acceleration = {
x: (velocity.x - prevVel.x) / deltaTime,
y: (velocity.y - prevVel.y) / deltaTime
};
}
mouseStateRef.current = {
position: normalizedPos,
prevPosition: prevPos,
velocity,
acceleration,
isHovering: true,
isDragging: state.isDragging
};
prevVelocityRef.current = velocity;
}, [toNormalized]);
const handleMouseMove = useCallback((e) => {
updatePosition(e.clientX, e.clientY);
}, [updatePosition]);
const handleMouseEnter = useCallback(() => {
mouseStateRef.current.isHovering = true;
}, []);
const handleMouseLeave = useCallback(() => {
mouseStateRef.current = {
position: null,
prevPosition: null,
velocity: { x: 0, y: 0 },
acceleration: { x: 0, y: 0 },
isHovering: false,
isDragging: false
};
prevVelocityRef.current = { x: 0, y: 0 };
}, []);
const handleMouseDown = useCallback(() => {
mouseStateRef.current.isDragging = true;
}, []);
const handleMouseUp = useCallback(() => {
mouseStateRef.current.isDragging = false;
}, []);
const handleTouchMove = useCallback((e) => {
if (e.touches.length > 0) {
const touch = e.touches[0];
updatePosition(touch.clientX, touch.clientY);
}
}, [updatePosition]);
const handleTouchStart = useCallback((e) => {
mouseStateRef.current.isDragging = true;
mouseStateRef.current.isHovering = true;
if (e.touches.length > 0) {
const touch = e.touches[0];
updatePosition(touch.clientX, touch.clientY);
}
}, [updatePosition]);
const handleTouchEnd = useCallback(() => {
mouseStateRef.current.isDragging = false;
mouseStateRef.current.isHovering = false;
mouseStateRef.current.position = null;
mouseStateRef.current.prevPosition = null;
mouseStateRef.current.velocity = { x: 0, y: 0 };
mouseStateRef.current.acceleration = { x: 0, y: 0 };
prevVelocityRef.current = { x: 0, y: 0 };
}, []);
useEffect2(() => {
const container = containerRef.current;
if (!container) return;
container.addEventListener("mousemove", handleMouseMove);
container.addEventListener("mouseenter", handleMouseEnter);
container.addEventListener("mouseleave", handleMouseLeave);
container.addEventListener("mousedown", handleMouseDown);
window.addEventListener("mouseup", handleMouseUp);
container.addEventListener("touchmove", handleTouchMove, { passive: true });
container.addEventListener("touchstart", handleTouchStart, { passive: true });
container.addEventListener("touchend", handleTouchEnd);
container.addEventListener("touchcancel", handleTouchEnd);
return () => {
container.removeEventListener("mousemove", handleMouseMove);
container.removeEventListener("mouseenter", handleMouseEnter);
container.removeEventListener("mouseleave", handleMouseLeave);
container.removeEventListener("mousedown", handleMouseDown);
window.removeEventListener("mouseup", handleMouseUp);
container.removeEventListener("touchmove", handleTouchMove);
container.removeEventListener("touchstart", handleTouchStart);
container.removeEventListener("touchend", handleTouchEnd);
container.removeEventListener("touchcancel", handleTouchEnd);
};
}, [containerRef, handleMouseMove, handleMouseEnter, handleMouseLeave, handleMouseDown, handleMouseUp, handleTouchMove, handleTouchStart, handleTouchEnd]);
const getState = useCallback(() => {
return { ...mouseStateRef.current };
}, []);
return {
getState
};
};
// src/engine/SpringPhysics.ts
var SpringPhysics = class {
constructor(config) {
this.config = config;
this.state = {
displacement: { x: 0, y: 0 },
velocity: { x: 0, y: 0 },
target: { x: 0, y: 0 }
};
}
/**
* 물리 파라미터 업데이트
*/
setConfig(config) {
this.config = { ...this.config, ...config };
}
/**
* 목표 위치 설정 (마우스 속도 기반)
*/
setTarget(velocity, velocityMultiplier = 1) {
this.state.target = {
x: velocity.x * velocityMultiplier,
y: velocity.y * velocityMultiplier
};
}
/**
* 초기 속도 설정 (드래그 방향과 속도를 즉시 반영)
* 드래그 방향으로 즉시 튕기는 효과
*/
setInitialVelocity(velocity, multiplier = 1) {
this.state.velocity = {
x: velocity.x * multiplier,
y: velocity.y * multiplier
};
this.state.target = { x: 0, y: 0 };
}
/**
* 스프링 물리 업데이트 (Hooke's Law + Damping)
* F = -k * x - c * v
* a = F / m
* v += a * dt
* x += v * dt
*/
update(deltaTime) {
const { stiffness, damping, mass } = this.config;
const { displacement, velocity, target } = this.state;
const dx = displacement.x - target.x;
const dy = displacement.y - target.y;
const springForceX = -stiffness * dx;
const springForceY = -stiffness * dy;
const dampingForceX = -damping * velocity.x;
const dampingForceY = -damping * velocity.y;
const totalForceX = springForceX + dampingForceX;
const totalForceY = springForceY + dampingForceY;
const accelerationX = totalForceX / mass;
const accelerationY = totalForceY / mass;
const newVelocityX = velocity.x + accelerationX * deltaTime;
const newVelocityY = velocity.y + accelerationY * deltaTime;
const newDisplacementX = displacement.x + newVelocityX * deltaTime;
const newDisplacementY = displacement.y + newVelocityY * deltaTime;
this.state = {
displacement: { x: newDisplacementX, y: newDisplacementY },
velocity: { x: newVelocityX, y: newVelocityY },
target
};
const isNearlyZero = (val) => Math.abs(val) < 1e-4;
if (isNearlyZero(this.state.displacement.x) && isNearlyZero(this.state.displacement.y) && isNearlyZero(this.state.velocity.x) && isNearlyZero(this.state.velocity.y)) {
this.reset();
}
return this.state.displacement;
}
/**
* 즉시 충격 적용 (마우스 가속도 기반)
*/
applyImpulse(acceleration, multiplier = 1) {
this.state.velocity.x += acceleration.x * multiplier;
this.state.velocity.y += acceleration.y * multiplier;
}
/**
* 현재 변위 가져오기
*/
getDisplacement() {
return { ...this.state.displacement };
}
/**
* 현재 속도 가져오기
*/
getVelocity() {
return { ...this.state.velocity };
}
/**
* 상태 리셋
*/
reset() {
this.state = {
displacement: { x: 0, y: 0 },
velocity: { x: 0, y: 0 },
target: { x: 0, y: 0 }
};
}
/**
* 마우스가 멈췄을 목표를 0으로 설정 (평형 상태로 복귀)
*/
returnToEquilibrium() {
this.state.target = { x: 0, y: 0 };
}
};
// src/hooks/useMouseInteraction.ts
var isPointInPolygon = (point, polygon) => {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].x, yi = polygon[i].y;
const xj = polygon[j].x, yj = polygon[j].y;
const intersect = yi > point.y !== yj > point.y && point.x < (xj - xi) * (point.y - yi) / (yj - yi) + xi;
if (intersect) inside = !inside;
}
return inside;
};
var useMouseInteraction = (containerRef, config) => {
const { getState } = useMouseVelocity(containerRef);
const [interactingAreaIndex, setInteractingAreaIndex] = useState(null);
const springPhysicsMapRef = useRef3(/* @__PURE__ */ new Map());
const getSpringPhysics = useCallback2((areaIndex) => {
if (!springPhysicsMapRef.current.has(areaIndex)) {
springPhysicsMapRef.current.set(areaIndex, new SpringPhysics(config.physics));
}
return springPhysicsMapRef.current.get(areaIndex);
}, [config.physics]);
const updateInteraction = useCallback2((areas, deltaTime) => {
if (!config.enabled) return areas;
const mouseState = getState();
if (mouseState.isDragging && mouseState.position) {
if (interactingAreaIndex === null) {
for (let i = areas.length - 1; i >= 0; i--) {
if (isPointInPolygon(mouseState.position, areas[i].basePoints)) {
setInteractingAreaIndex(i);
getSpringPhysics(i).reset();
break;
}
}
}
if (interactingAreaIndex !== null) {
const velocityMult = config.velocityMultiplier || 1;
const spring = getSpringPhysics(interactingAreaIndex);
const velocityMag = Math.sqrt(
mouseState.velocity.x ** 2 + mouseState.velocity.y ** 2
);
const minVel = config.minVelocity || 0.05;
const maxVel = config.maxVelocity || 5;
if (velocityMag >= minVel) {
let clampedVelocity = mouseState.velocity;
if (velocityMag > maxVel) {
const scale = maxVel / velocityMag;
clampedVelocity = {
x: mouseState.velocity.x * scale,
y: mouseState.velocity.y * scale
};
}
spring.setTarget(clampedVelocity, velocityMult);
} else {
spring.returnToEquilibrium();
}
}
} else {
if (interactingAreaIndex !== null) {
const velocityMult = config.velocityMultiplier || 1;
const spring = getSpringPhysics(interactingAreaIndex);
const maxVel = config.maxVelocity || 5;
const velocityMag = Math.sqrt(
mouseState.velocity.x ** 2 + mouseState.velocity.y ** 2
);
let clampedVelocity = mouseState.velocity;
if (velocityMag > maxVel) {
const scale = maxVel / velocityMag;
clampedVelocity = {
x: mouseState.velocity.x * scale,
y: mouseState.velocity.y * scale
};
}
spring.setInitialVelocity(clampedVelocity, velocityMult);
setInteractingAreaIndex(null);
}
}
return areas.map((area, index) => {
const spring = springPhysicsMapRef.current.get(index);
if (!spring) return area;
const springVelocity = spring.getVelocity();
const springDisplacement = spring.getDisplacement();
const isSpringActive = Math.sqrt(springVelocity.x ** 2 + springVelocity.y ** 2) > 1e-3 || Math.sqrt(springDisplacement.x ** 2 + springDisplacement.y ** 2) > 1e-3;
if (index !== interactingAreaIndex && !isSpringActive) {
return area;
}
const displacement = spring.update(deltaTime);
const displacementMag = Math.sqrt(displacement.x ** 2 + displacement.y ** 2);
if (displacementMag < 1e-3) {
return area;
}
return {
...area,
dragVector: {
x: area.dragVector.x - displacement.x,
y: area.dragVector.y - displacement.y
}
};
});
}, [config, getState, interactingAreaIndex, getSpringPhysics]);
const updateConfig = useCallback2((newConfig) => {
const physicsConfig = newConfig.physics;
if (physicsConfig) {
springPhysicsMapRef.current.forEach((spring) => {
spring.setConfig(physicsConfig);
});
}
}, []);
const reset = useCallback2(() => {
springPhysicsMapRef.current.forEach((spring) => {
spring.reset();
});
setInteractingAreaIndex(null);
}, []);
return {
updateInteraction,
updateConfig,
reset
};
};
// src/utils/constants.ts // src/utils/constants.ts
var SHADER_CONFIG = { var SHADER_CONFIG = {
/** 최대 영역 개수 */ /** 최대 영역 개수 */
@ -308,19 +679,38 @@ var ImageDistortion = ({
fragmentShaderPath, fragmentShaderPath,
isPlaying = true, isPlaying = true,
style, style,
className className,
mouseInteraction
}) => { }) => {
const containerRef = useRef2(null); const containerRef = useRef4(null);
const sceneRef = useRef2(null); const sceneRef = useRef4(null);
const shaderManagerRef = useRef2(new ShaderManager()); const shaderManagerRef = useRef4(new ShaderManager());
const textureRef = useRef2(null); const textureRef = useRef4(null);
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState2(false);
const [imageLoaded, setImageLoaded] = useState(false); const [imageLoaded, setImageLoaded] = useState2(false);
const [currentAreas, setCurrentAreas] = useState(areas); const [currentAreas, setCurrentAreas] = useState2(areas);
useEffect2(() => { const mouseInteractionHook = useMouseInteraction(
containerRef,
mouseInteraction || {
enabled: false,
physics: {
stiffness: 100,
damping: 10,
mass: 1,
influenceRadius: 0.2,
maxStrength: 1
}
}
);
useEffect3(() => {
setCurrentAreas(areas); setCurrentAreas(areas);
}, [areas]); }, [areas]);
useEffect2(() => { useEffect3(() => {
if (mouseInteraction) {
mouseInteractionHook.updateConfig(mouseInteraction);
}
}, [mouseInteraction, mouseInteractionHook]);
useEffect3(() => {
console.log("[ImageDistortion] useEffect \uC2E4\uD589, containerRef.current:", containerRef.current); console.log("[ImageDistortion] useEffect \uC2E4\uD589, containerRef.current:", containerRef.current);
if (!containerRef.current) { if (!containerRef.current) {
console.warn("[ImageDistortion] containerRef.current\uAC00 null\uC785\uB2C8\uB2E4. \uCEF4\uD3EC\uB10C\uD2B8\uAC00 \uC81C\uB300\uB85C \uB9C8\uC6B4\uD2B8\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4."); console.warn("[ImageDistortion] containerRef.current\uAC00 null\uC785\uB2C8\uB2E4. \uCEF4\uD3EC\uB10C\uD2B8\uAC00 \uC81C\uB300\uB85C \uB9C8\uC6B4\uD2B8\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4.");
@ -346,7 +736,7 @@ var ImageDistortion = ({
} }
}; };
}, [vertexShaderPath, fragmentShaderPath]); }, [vertexShaderPath, fragmentShaderPath]);
useEffect2(() => { useEffect3(() => {
if (!imageSrc || !isReady) { if (!imageSrc || !isReady) {
console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2A4\uD0B5:", { imageSrc, isReady }); console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2A4\uD0B5:", { imageSrc, isReady });
return; return;
@ -389,7 +779,7 @@ var ImageDistortion = ({
} }
}; };
}, [imageSrc, isReady]); }, [imageSrc, isReady]);
useEffect2(() => { useEffect3(() => {
if (!sceneRef.current || !isReady) return; if (!sceneRef.current || !isReady) return;
const resolution = sceneRef.current.getResolution(); const resolution = sceneRef.current.getResolution();
const points = new Float32Array(SHADER_CONFIG.MAX_POINTS * 2); const points = new Float32Array(SHADER_CONFIG.MAX_POINTS * 2);
@ -418,14 +808,18 @@ var ImageDistortion = ({
}); });
sceneRef.current.render(); sceneRef.current.render();
}, [currentAreas, isReady]); }, [currentAreas, isReady]);
const animationCallback = useCallback((deltaTime) => { const animationCallback = useCallback3((deltaTime) => {
if (!isReady) return; if (!isReady) return;
setCurrentAreas((prevAreas) => { setCurrentAreas((prevAreas) => {
const updatedAreas = AnimationLoop.updateProgress(prevAreas, deltaTime); let updatedAreas = AnimationLoop.updateProgress(prevAreas, deltaTime);
return AnimationLoop.updateAreaDragVectors(updatedAreas); updatedAreas = AnimationLoop.updateAreaDragVectors(updatedAreas);
if (mouseInteraction?.enabled) {
updatedAreas = mouseInteractionHook.updateInteraction(updatedAreas, deltaTime);
}
return updatedAreas;
}); });
}, [isReady]); }, [isReady, mouseInteraction, mouseInteractionHook]);
useAnimationFrame(animationCallback, isPlaying); useAnimationFrame(animationCallback, isPlaying || mouseInteraction?.enabled || false);
return /* @__PURE__ */ jsx( return /* @__PURE__ */ jsx(
"div", "div",
{ {
@ -459,28 +853,28 @@ var ImageDistortion = ({
}; };
// src/editor/DistortionEditor.tsx // src/editor/DistortionEditor.tsx
import { useEffect as useEffect4, useState as useState4 } from "react"; import { useEffect as useEffect5, useState as useState5 } from "react";
// src/editor/hooks/useDistortionEditor.ts // src/editor/hooks/useDistortionEditor.ts
import { useState as useState2, useCallback as useCallback2 } from "react"; import { useState as useState3, useCallback as useCallback4 } from "react";
var useDistortionEditor = (initialAreas = []) => { var useDistortionEditor = (initialAreas = []) => {
const [state, setState] = useState2({ const [state, setState] = useState3({
selectedAreaId: initialAreas[0]?.id || null, selectedAreaId: initialAreas[0]?.id || null,
areas: initialAreas, areas: initialAreas,
editMode: "normal", editMode: "normal",
draggingPointIndex: null draggingPointIndex: null
}); });
const selectArea = useCallback2((areaId) => { const selectArea = useCallback4((areaId) => {
setState((prev) => ({ ...prev, selectedAreaId: areaId })); setState((prev) => ({ ...prev, selectedAreaId: areaId }));
}, []); }, []);
const addArea = useCallback2((area) => { const addArea = useCallback4((area) => {
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
areas: [...prev.areas, area], areas: [...prev.areas, area],
selectedAreaId: area.id selectedAreaId: area.id
})); }));
}, []); }, []);
const removeArea = useCallback2((areaId) => { const removeArea = useCallback4((areaId) => {
setState((prev) => { setState((prev) => {
const newAreas = prev.areas.filter((a) => a.id !== areaId); const newAreas = prev.areas.filter((a) => a.id !== areaId);
return { return {
@ -490,13 +884,13 @@ var useDistortionEditor = (initialAreas = []) => {
}; };
}); });
}, []); }, []);
const updateArea = useCallback2((areaId, updates) => { const updateArea = useCallback4((areaId, updates) => {
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
areas: prev.areas.map((area) => area.id === areaId ? { ...area, ...updates } : area) areas: prev.areas.map((area) => area.id === areaId ? { ...area, ...updates } : area)
})); }));
}, []); }, []);
const updatePoint = useCallback2((areaId, pointIndex, point) => { const updatePoint = useCallback4((areaId, pointIndex, point) => {
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
areas: prev.areas.map((area) => { areas: prev.areas.map((area) => {
@ -509,16 +903,16 @@ var useDistortionEditor = (initialAreas = []) => {
}) })
})); }));
}, []); }, []);
const startDragging = useCallback2((pointIndex) => { const startDragging = useCallback4((pointIndex) => {
setState((prev) => ({ ...prev, draggingPointIndex: pointIndex })); setState((prev) => ({ ...prev, draggingPointIndex: pointIndex }));
}, []); }, []);
const stopDragging = useCallback2(() => { const stopDragging = useCallback4(() => {
setState((prev) => ({ ...prev, draggingPointIndex: null })); setState((prev) => ({ ...prev, draggingPointIndex: null }));
}, []); }, []);
const setEditMode = useCallback2((mode) => { const setEditMode = useCallback4((mode) => {
setState((prev) => ({ ...prev, editMode: mode })); setState((prev) => ({ ...prev, editMode: mode }));
}, []); }, []);
const getSelectedArea = useCallback2(() => { const getSelectedArea = useCallback4(() => {
return state.areas.find((a) => a.id === state.selectedAreaId) || null; return state.areas.find((a) => a.id === state.selectedAreaId) || null;
}, [state.areas, state.selectedAreaId]); }, [state.areas, state.selectedAreaId]);
return { return {
@ -536,7 +930,7 @@ var useDistortionEditor = (initialAreas = []) => {
}; };
// src/editor/components/EditorCanvas.tsx // src/editor/components/EditorCanvas.tsx
import { useRef as useRef3, useEffect as useEffect3, useState as useState3, useCallback as useCallback3, useMemo } from "react"; import { useRef as useRef5, useEffect as useEffect4, useState as useState4, useCallback as useCallback5, useMemo } from "react";
// src/editor/constants.ts // src/editor/constants.ts
var DEFAULT_EDITOR_CANVAS_STYLE = { var DEFAULT_EDITOR_CANVAS_STYLE = {
@ -612,10 +1006,10 @@ var EditorCanvas = ({
style: customStyle, style: customStyle,
showEditor = true showEditor = true
}) => { }) => {
const containerRef = useRef3(null); const containerRef = useRef5(null);
const [canvasSize, setCanvasSize] = useState3({ width: 0, height: 0 }); const [canvasSize, setCanvasSize] = useState4({ width: 0, height: 0 });
const [isDraggingArea, setIsDraggingArea] = useState3(false); const [isDraggingArea, setIsDraggingArea] = useState4(false);
const [dragStartPos, setDragStartPos] = useState3(null); const [dragStartPos, setDragStartPos] = useState4(null);
const editorStyle = useMemo(() => ({ const editorStyle = useMemo(() => ({
...DEFAULT_EDITOR_CANVAS_STYLE, ...DEFAULT_EDITOR_CANVAS_STYLE,
...customStyle, ...customStyle,
@ -633,13 +1027,13 @@ var EditorCanvas = ({
...customStyle?.areaOutline ...customStyle?.areaOutline
} }
}), [customStyle]); }), [customStyle]);
useEffect3(() => { useEffect4(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect(); const rect = containerRef.current.getBoundingClientRect();
setCanvasSize({ width: rect.width, height: rect.height }); setCanvasSize({ width: rect.width, height: rect.height });
}, [width, height]); }, [width, height]);
const selectedArea = areas.find((a) => a.id === selectedAreaId); const selectedArea = areas.find((a) => a.id === selectedAreaId);
const isPointInPolygon = useCallback3((point, polygon) => { const isPointInPolygon2 = useCallback5((point, polygon) => {
let inside = false; let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].x, yi = polygon[i].y; const xi = polygon[i].x, yi = polygon[i].y;
@ -649,7 +1043,7 @@ var EditorCanvas = ({
} }
return inside; return inside;
}, []); }, []);
const handleMouseDown = useCallback3( const handleMouseDown = useCallback5(
(pointIndex) => (e) => { (pointIndex) => (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -657,22 +1051,22 @@ var EditorCanvas = ({
}, },
[onStartDragging] [onStartDragging]
); );
const handleCanvasMouseDown = useCallback3( const handleCanvasMouseDown = useCallback5(
(e) => { (e) => {
if (!showEditor || !selectedArea || !containerRef.current) return; if (!showEditor || !selectedArea || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect(); const rect = containerRef.current.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width; const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height; const y = (e.clientY - rect.top) / rect.height;
const clickPoint = { x, y }; const clickPoint = { x, y };
if (isPointInPolygon(clickPoint, selectedArea.basePoints)) { if (isPointInPolygon2(clickPoint, selectedArea.basePoints)) {
setIsDraggingArea(true); setIsDraggingArea(true);
setDragStartPos(clickPoint); setDragStartPos(clickPoint);
e.preventDefault(); e.preventDefault();
} }
}, },
[showEditor, selectedArea, isPointInPolygon] [showEditor, selectedArea, isPointInPolygon2]
); );
const handleMouseMove = useCallback3( const handleMouseMove = useCallback5(
(e) => { (e) => {
if (!showEditor || !selectedArea || !containerRef.current) return; if (!showEditor || !selectedArea || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect(); const rect = containerRef.current.getBoundingClientRect();
@ -695,7 +1089,7 @@ var EditorCanvas = ({
}, },
[showEditor, draggingPointIndex, isDraggingArea, dragStartPos, selectedArea, onUpdatePoint, onUpdateArea] [showEditor, draggingPointIndex, isDraggingArea, dragStartPos, selectedArea, onUpdatePoint, onUpdateArea]
); );
const handleMouseUp = useCallback3(() => { const handleMouseUp = useCallback5(() => {
if (draggingPointIndex !== null) { if (draggingPointIndex !== null) {
onStopDragging(); onStopDragging();
} }
@ -704,7 +1098,7 @@ var EditorCanvas = ({
setDragStartPos(null); setDragStartPos(null);
} }
}, [draggingPointIndex, isDraggingArea, onStopDragging]); }, [draggingPointIndex, isDraggingArea, onStopDragging]);
useEffect3(() => { useEffect4(() => {
if (draggingPointIndex !== null || isDraggingArea) { if (draggingPointIndex !== null || isDraggingArea) {
window.addEventListener("mouseup", handleMouseUp); window.addEventListener("mouseup", handleMouseUp);
return () => window.removeEventListener("mouseup", handleMouseUp); return () => window.removeEventListener("mouseup", handleMouseUp);
@ -723,7 +1117,7 @@ var EditorCanvas = ({
y: posY * canvasHeight y: posY * canvasHeight
}; };
}; };
const drawDistortionCircle = useCallback3((ctx, points, canvasWidth, canvasHeight) => { const drawDistortionCircle = useCallback5((ctx, points, canvasWidth, canvasHeight) => {
const segments = 128; const segments = 128;
const centerU = 0.5; const centerU = 0.5;
const centerV = 0.5; const centerV = 0.5;
@ -1111,11 +1505,11 @@ var DistortionEditor = ({
stopDragging, stopDragging,
getSelectedArea getSelectedArea
} = useDistortionEditor(initialAreas); } = useDistortionEditor(initialAreas);
const [showEditor, setShowEditor] = useState4(true); const [showEditor, setShowEditor] = useState5(true);
useEffect4(() => { useEffect5(() => {
onAreasChange?.(state.areas); onAreasChange?.(state.areas);
}, [state.areas, onAreasChange]); }, [state.areas, onAreasChange]);
useEffect4(() => { useEffect5(() => {
onSelectedAreaChange?.(state.selectedAreaId); onSelectedAreaChange?.(state.selectedAreaId);
}, [state.selectedAreaId, onSelectedAreaChange]); }, [state.selectedAreaId, onSelectedAreaChange]);
const handleAddArea = () => { const handleAddArea = () => {
@ -1197,9 +1591,12 @@ export {
ImageDistortion, ImageDistortion,
SHADER_CONFIG, SHADER_CONFIG,
ShaderManager, ShaderManager,
SpringPhysics,
ThreeScene, ThreeScene,
applyEasing, applyEasing,
useAnimationFrame, useAnimationFrame,
useDistortionEditor useDistortionEditor,
useMouseInteraction,
useMouseVelocity
}; };
//# sourceMappingURL=index.mjs.map //# sourceMappingURL=index.mjs.map

2
dist/index.mjs.map vendored

File diff suppressed because one or more lines are too long

View File

@ -5,7 +5,9 @@ import { ThreeScene } from '../engine/ThreeScene';
import { ShaderManager } from '../engine/ShaderManager'; import { ShaderManager } from '../engine/ShaderManager';
import { AnimationLoop } from '../engine/AnimationLoop'; import { AnimationLoop } from '../engine/AnimationLoop';
import { useAnimationFrame } from '../hooks/useAnimationFrame'; import { useAnimationFrame } from '../hooks/useAnimationFrame';
import { useMouseInteraction } from '../hooks/useMouseInteraction';
import { SHADER_CONFIG } from '../utils/constants'; import { SHADER_CONFIG } from '../utils/constants';
import { MouseInteractionConfig } from '../types/interaction';
/** /**
* ImageDistortion Props * ImageDistortion Props
@ -25,6 +27,8 @@ export interface ImageDistortionProps {
style?: React.CSSProperties; style?: React.CSSProperties;
/** 컨테이너 클래스명 */ /** 컨테이너 클래스명 */
className?: string; className?: string;
/** 마우스 인터랙션 설정 */
mouseInteraction?: MouseInteractionConfig;
} }
/** /**
@ -39,6 +43,7 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
isPlaying = true, isPlaying = true,
style, style,
className, className,
mouseInteraction,
}) => { }) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const sceneRef = useRef<ThreeScene | null>(null); const sceneRef = useRef<ThreeScene | null>(null);
@ -49,11 +54,33 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
const [imageLoaded, setImageLoaded] = useState(false); const [imageLoaded, setImageLoaded] = useState(false);
const [currentAreas, setCurrentAreas] = useState<DistortionArea[]>(areas); const [currentAreas, setCurrentAreas] = useState<DistortionArea[]>(areas);
// 마우스 인터랙션 훅
const mouseInteractionHook = useMouseInteraction(
containerRef,
mouseInteraction || {
enabled: false,
physics: {
stiffness: 100,
damping: 10,
mass: 1,
influenceRadius: 0.2,
maxStrength: 1.0,
},
}
);
// 영역 변경 시 상태 업데이트 // 영역 변경 시 상태 업데이트
useEffect(() => { useEffect(() => {
setCurrentAreas(areas); setCurrentAreas(areas);
}, [areas]); }, [areas]);
// 마우스 인터랙션 설정 변경 시 업데이트
useEffect(() => {
if (mouseInteraction) {
mouseInteractionHook.updateConfig(mouseInteraction);
}
}, [mouseInteraction, mouseInteractionHook]);
// Three.js 씬 초기화 // Three.js 씬 초기화
useEffect(() => { useEffect(() => {
console.log('[ImageDistortion] useEffect 실행, containerRef.current:', containerRef.current); console.log('[ImageDistortion] useEffect 실행, containerRef.current:', containerRef.current);
@ -187,14 +214,21 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
if (!isReady) return; if (!isReady) return;
setCurrentAreas((prevAreas) => { setCurrentAreas((prevAreas) => {
// 진행도 업데이트 // 1. 기존 영역 애니메이션 업데이트
const updatedAreas = AnimationLoop.updateProgress(prevAreas, deltaTime); let updatedAreas = AnimationLoop.updateProgress(prevAreas, deltaTime);
// 드래그 벡터 업데이트 updatedAreas = AnimationLoop.updateAreaDragVectors(updatedAreas);
return AnimationLoop.updateAreaDragVectors(updatedAreas);
});
}, [isReady]);
useAnimationFrame(animationCallback, isPlaying); // 2. 마우스 인터랙션 적용 (기존 dragVector에 스프링 변위 추가)
if (mouseInteraction?.enabled) {
updatedAreas = mouseInteractionHook.updateInteraction(updatedAreas, deltaTime);
}
return updatedAreas;
});
}, [isReady, mouseInteraction, mouseInteractionHook]);
// 애니메이션은 항상 실행 (마우스 인터랙션 포함)
useAnimationFrame(animationCallback, isPlaying || mouseInteraction?.enabled || false);
return ( return (
<div <div

View File

@ -16,6 +16,14 @@ export class AnimationLoop {
return areas.map((area) => { return areas.map((area) => {
const { progress, movement } = area; const { progress, movement } = area;
// duration이 0이면 애니메이션 없음 (dragVector를 0으로 유지)
if (movement.duration <= 0) {
return {
...area,
dragVector: { x: 0, y: 0 },
};
}
// 이징 적용 // 이징 적용
const easedProgress = applyEasing(progress, movement.easing); const easedProgress = applyEasing(progress, movement.easing);
@ -56,6 +64,11 @@ export class AnimationLoop {
deltaTime: number deltaTime: number
): DistortionArea[] { ): DistortionArea[] {
return areas.map((area) => { return areas.map((area) => {
// duration이 0이면 progress 업데이트 안 함
if (area.movement.duration <= 0) {
return area;
}
let newProgress = area.progress + deltaTime / area.movement.duration; let newProgress = area.progress + deltaTime / area.movement.duration;
newProgress %= 1.0; // 루프 newProgress %= 1.0; // 루프

149
src/engine/SpringPhysics.ts Normal file
View File

@ -0,0 +1,149 @@
import { Point } from '../types/area';
import { SpringPhysicsConfig, SpringState } from '../types/interaction';
/**
*
* Hooke's Law와 -
*/
export class SpringPhysics {
private config: SpringPhysicsConfig;
private state: SpringState;
constructor(config: SpringPhysicsConfig) {
this.config = config;
this.state = {
displacement: { x: 0, y: 0 },
velocity: { x: 0, y: 0 },
target: { x: 0, y: 0 },
};
}
/**
*
*/
public setConfig(config: Partial<SpringPhysicsConfig>) {
this.config = { ...this.config, ...config };
}
/**
* ( )
*/
public setTarget(velocity: Point, velocityMultiplier: number = 1.0) {
// 속도에 승수를 곱해서 목표 변위로 설정
this.state.target = {
x: velocity.x * velocityMultiplier,
y: velocity.y * velocityMultiplier,
};
}
/**
* ( )
*
*/
public setInitialVelocity(velocity: Point, multiplier: number = 1.0) {
// 현재 속도를 즉시 변경
this.state.velocity = {
x: velocity.x * multiplier,
y: velocity.y * multiplier,
};
// 목표는 0 (평형 상태로 돌아가도록)
this.state.target = { x: 0, y: 0 };
}
/**
* (Hooke's Law + Damping)
* F = -k * x - c * v
* a = F / m
* v += a * dt
* x += v * dt
*/
public update(deltaTime: number): Point {
const { stiffness, damping, mass } = this.config;
const { displacement, velocity, target } = this.state;
// 평형 위치로부터의 변위 (target은 마우스 속도에서 계산된 목표)
const dx = displacement.x - target.x;
const dy = displacement.y - target.y;
// 스프링 힘: F = -k * x (복원력)
const springForceX = -stiffness * dx;
const springForceY = -stiffness * dy;
// 감쇠 힘: F = -c * v (마찰력)
const dampingForceX = -damping * velocity.x;
const dampingForceY = -damping * velocity.y;
// 총 힘
const totalForceX = springForceX + dampingForceX;
const totalForceY = springForceY + dampingForceY;
// 가속도: a = F / m
const accelerationX = totalForceX / mass;
const accelerationY = totalForceY / mass;
// 속도 업데이트: v += a * dt
const newVelocityX = velocity.x + accelerationX * deltaTime;
const newVelocityY = velocity.y + accelerationY * deltaTime;
// 위치 업데이트: x += v * dt
const newDisplacementX = displacement.x + newVelocityX * deltaTime;
const newDisplacementY = displacement.y + newVelocityY * deltaTime;
// 상태 저장
this.state = {
displacement: { x: newDisplacementX, y: newDisplacementY },
velocity: { x: newVelocityX, y: newVelocityY },
target,
};
// 매우 작은 움직임은 0으로 처리 (정지 판정)
const isNearlyZero = (val: number) => Math.abs(val) < 0.0001;
if (isNearlyZero(this.state.displacement.x) && isNearlyZero(this.state.displacement.y) &&
isNearlyZero(this.state.velocity.x) && isNearlyZero(this.state.velocity.y)) {
this.reset();
}
return this.state.displacement;
}
/**
* ( )
*/
public applyImpulse(acceleration: Point, multiplier: number = 1.0) {
// 가속도를 속도로 변환하여 즉시 적용
this.state.velocity.x += acceleration.x * multiplier;
this.state.velocity.y += acceleration.y * multiplier;
}
/**
*
*/
public getDisplacement(): Point {
return { ...this.state.displacement };
}
/**
*
*/
public getVelocity(): Point {
return { ...this.state.velocity };
}
/**
*
*/
public reset() {
this.state = {
displacement: { x: 0, y: 0 },
velocity: { x: 0, y: 0 },
target: { x: 0, y: 0 },
};
}
/**
* 0 ( )
*/
public returnToEquilibrium() {
this.state.target = { x: 0, y: 0 };
}
}

View File

@ -111,7 +111,6 @@ export class ThreeScene {
* *
*/ */
public render() { public render() {
console.log('[ThreeScene] render() 호출됨, mesh:', this.mesh);
this.renderer.render(this.scene, this.camera); this.renderer.render(this.scene, this.camera);
} }

View File

@ -0,0 +1,190 @@
import { useRef, useCallback, useState } from 'react';
import { useMouseVelocity } from './useMouseVelocity';
import { SpringPhysics } from '../engine/SpringPhysics';
import { DistortionArea, Point } from '../types/area';
import { MouseInteractionConfig } from '../types/interaction';
/**
*
*/
const isPointInPolygon = (point: Point, polygon: Point[]): boolean => {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].x, yi = polygon[i].y;
const xj = polygon[j].x, yj = polygon[j].y;
const intersect = ((yi > point.y) !== (yj > point.y))
&& (point.x < (xj - xi) * (point.y - yi) / (yj - yi) + xi);
if (intersect) inside = !inside;
}
return inside;
};
/**
*
*
*/
export const useMouseInteraction = (
containerRef: React.RefObject<HTMLElement | null>,
config: MouseInteractionConfig
) => {
const { getState } = useMouseVelocity(containerRef);
const [interactingAreaIndex, setInteractingAreaIndex] = useState<number | null>(null);
const springPhysicsMapRef = useRef<Map<number, SpringPhysics>>(new Map());
/**
* ( )
*/
const getSpringPhysics = useCallback((areaIndex: number): SpringPhysics => {
if (!springPhysicsMapRef.current.has(areaIndex)) {
springPhysicsMapRef.current.set(areaIndex, new SpringPhysics(config.physics));
}
return springPhysicsMapRef.current.get(areaIndex)!;
}, [config.physics]);
/**
* dragVector를
*/
const updateInteraction = useCallback((areas: DistortionArea[], deltaTime: number): DistortionArea[] => {
if (!config.enabled) return areas;
const mouseState = getState();
// 마우스 클릭/드래그 중이고 위치가 있으면
if (mouseState.isDragging && mouseState.position) {
// 아직 영역을 선택하지 않았으면 찾기
if (interactingAreaIndex === null) {
// 마우스 위치가 포함된 영역 찾기 (마지막 영역부터 - 위에 있는 영역 우선)
for (let i = areas.length - 1; i >= 0; i--) {
if (isPointInPolygon(mouseState.position, areas[i].basePoints)) {
setInteractingAreaIndex(i);
// 해당 영역의 스프링 리셋
getSpringPhysics(i).reset();
break;
}
}
}
// 드래그 중인 영역이 있으면 마우스 방향으로 실시간 늘어남
if (interactingAreaIndex !== null) {
const velocityMult = config.velocityMultiplier || 1.0;
const spring = getSpringPhysics(interactingAreaIndex);
// 속도 크기 확인
const velocityMag = Math.sqrt(
mouseState.velocity.x ** 2 + mouseState.velocity.y ** 2
);
const minVel = config.minVelocity || 0.05;
const maxVel = config.maxVelocity || 5.0;
if (velocityMag >= minVel) {
// 속도 클램핑 (너무 빠른 움직임 제한)
let clampedVelocity = mouseState.velocity;
if (velocityMag > maxVel) {
const scale = maxVel / velocityMag;
clampedVelocity = {
x: mouseState.velocity.x * scale,
y: mouseState.velocity.y * scale,
};
}
// 드래그 중: 클램핑된 마우스 속도를 목표로 설정
spring.setTarget(clampedVelocity, velocityMult);
} else {
// 드래그 중이지만 마우스가 멈춰있으면 평형으로 복귀
spring.returnToEquilibrium();
}
}
} else {
// 마우스를 놓았으면 마지막 속도를 초기 속도로 설정하여 튕김
if (interactingAreaIndex !== null) {
const velocityMult = config.velocityMultiplier || 1.0;
const spring = getSpringPhysics(interactingAreaIndex);
const maxVel = config.maxVelocity || 5.0;
// 속도 클램핑 (놓을 때도 제한)
const velocityMag = Math.sqrt(
mouseState.velocity.x ** 2 + mouseState.velocity.y ** 2
);
let clampedVelocity = mouseState.velocity;
if (velocityMag > maxVel) {
const scale = maxVel / velocityMag;
clampedVelocity = {
x: mouseState.velocity.x * scale,
y: mouseState.velocity.y * scale,
};
}
// 클램핑된 속도를 초기 속도로 설정하여 튕김
spring.setInitialVelocity(clampedVelocity, velocityMult);
setInteractingAreaIndex(null);
}
}
// 모든 영역의 스프링 물리 업데이트
return areas.map((area, index) => {
const spring = springPhysicsMapRef.current.get(index);
if (!spring) return area;
// 현재 드래그 중인 영역이거나 스프링이 활성 상태일 때만 업데이트
const springVelocity = spring.getVelocity();
const springDisplacement = spring.getDisplacement();
const isSpringActive = Math.sqrt(springVelocity.x ** 2 + springVelocity.y ** 2) > 0.001 ||
Math.sqrt(springDisplacement.x ** 2 + springDisplacement.y ** 2) > 0.001;
// 드래그 중이 아니고 스프링도 비활성이면 업데이트 안 함
if (index !== interactingAreaIndex && !isSpringActive) {
return area;
}
// 스프링 물리 업데이트
const displacement = spring.update(deltaTime);
// 변위가 거의 0이면 원래 dragVector 유지
const displacementMag = Math.sqrt(displacement.x ** 2 + displacement.y ** 2);
if (displacementMag < 0.001) {
return area;
}
// 스프링 변위를 dragVector에 추가 (기존 애니메이션과 혼합)
// dragVector는 텍스처 샘플링 좌표 이동이므로,
// 마우스 드래그 방향과 반대로 적용해야 이미지가 드래그 방향으로 밀림
// 예: 우→좌 드래그(velocity < 0) → dragVector > 0 → 이미지 왼쪽으로 밀림
return {
...area,
dragVector: {
x: area.dragVector.x - displacement.x,
y: area.dragVector.y - displacement.y,
},
};
});
}, [config, getState, interactingAreaIndex, getSpringPhysics]);
/**
*
*/
const updateConfig = useCallback((newConfig: Partial<MouseInteractionConfig>) => {
const physicsConfig = newConfig.physics;
if (physicsConfig) {
// 모든 스프링 물리 엔진의 설정 업데이트
springPhysicsMapRef.current.forEach((spring) => {
spring.setConfig(physicsConfig);
});
}
}, []);
/**
*
*/
const reset = useCallback(() => {
springPhysicsMapRef.current.forEach((spring) => {
spring.reset();
});
setInteractingAreaIndex(null);
}, []);
return {
updateInteraction,
updateConfig,
reset,
};
};

View File

@ -0,0 +1,202 @@
import { useRef, useCallback, useEffect } from 'react';
import { Point } from '../types/area';
import { MouseState } from '../types/interaction';
/**
* , ,
*/
export const useMouseVelocity = (containerRef: React.RefObject<HTMLElement | null>) => {
const mouseStateRef = useRef<MouseState>({
position: null,
prevPosition: null,
velocity: { x: 0, y: 0 },
acceleration: { x: 0, y: 0 },
isHovering: false,
isDragging: false,
});
const lastUpdateTimeRef = useRef<number>(Date.now());
const prevVelocityRef = useRef<Point>({ x: 0, y: 0 });
/**
* (0-1)
*/
const toNormalized = useCallback((clientX: number, clientY: number): Point | null => {
if (!containerRef.current) return null;
const rect = containerRef.current.getBoundingClientRect();
return {
x: (clientX - rect.left) / rect.width,
y: (clientY - rect.top) / rect.height,
};
}, [containerRef]);
/**
* (/ )
*/
const updatePosition = useCallback((clientX: number, clientY: number) => {
const now = Date.now();
const deltaTime = (now - lastUpdateTimeRef.current) / 1000; // 초 단위
lastUpdateTimeRef.current = now;
const normalizedPos = toNormalized(clientX, clientY);
if (!normalizedPos) return;
const state = mouseStateRef.current;
const prevPos = state.position;
// 속도 계산 (변위 / 시간)
let velocity: Point = { x: 0, y: 0 };
if (prevPos && deltaTime > 0) {
velocity = {
x: (normalizedPos.x - prevPos.x) / deltaTime,
y: (normalizedPos.y - prevPos.y) / deltaTime,
};
}
// 가속도 계산 (속도 변화 / 시간)
const prevVel = prevVelocityRef.current;
let acceleration: Point = { x: 0, y: 0 };
if (deltaTime > 0) {
acceleration = {
x: (velocity.x - prevVel.x) / deltaTime,
y: (velocity.y - prevVel.y) / deltaTime,
};
}
// 상태 업데이트
mouseStateRef.current = {
position: normalizedPos,
prevPosition: prevPos,
velocity,
acceleration,
isHovering: true,
isDragging: state.isDragging,
};
prevVelocityRef.current = velocity;
}, [toNormalized]);
/**
*
*/
const handleMouseMove = useCallback((e: MouseEvent) => {
updatePosition(e.clientX, e.clientY);
}, [updatePosition]);
/**
*
*/
const handleMouseEnter = useCallback(() => {
mouseStateRef.current.isHovering = true;
}, []);
/**
*
*/
const handleMouseLeave = useCallback(() => {
mouseStateRef.current = {
position: null,
prevPosition: null,
velocity: { x: 0, y: 0 },
acceleration: { x: 0, y: 0 },
isHovering: false,
isDragging: false,
};
prevVelocityRef.current = { x: 0, y: 0 };
}, []);
/**
*
*/
const handleMouseDown = useCallback(() => {
mouseStateRef.current.isDragging = true;
}, []);
/**
*
*/
const handleMouseUp = useCallback(() => {
mouseStateRef.current.isDragging = false;
}, []);
/**
*
*/
const handleTouchMove = useCallback((e: TouchEvent) => {
if (e.touches.length > 0) {
const touch = e.touches[0];
updatePosition(touch.clientX, touch.clientY);
}
}, [updatePosition]);
/**
*
*/
const handleTouchStart = useCallback((e: TouchEvent) => {
mouseStateRef.current.isDragging = true;
mouseStateRef.current.isHovering = true;
if (e.touches.length > 0) {
const touch = e.touches[0];
updatePosition(touch.clientX, touch.clientY);
}
}, [updatePosition]);
/**
*
*/
const handleTouchEnd = useCallback(() => {
mouseStateRef.current.isDragging = false;
mouseStateRef.current.isHovering = false;
mouseStateRef.current.position = null;
mouseStateRef.current.prevPosition = null;
mouseStateRef.current.velocity = { x: 0, y: 0 };
mouseStateRef.current.acceleration = { x: 0, y: 0 };
prevVelocityRef.current = { x: 0, y: 0 };
}, []);
/**
*
*/
useEffect(() => {
const container = containerRef.current;
if (!container) return;
// 마우스 이벤트
container.addEventListener('mousemove', handleMouseMove);
container.addEventListener('mouseenter', handleMouseEnter);
container.addEventListener('mouseleave', handleMouseLeave);
container.addEventListener('mousedown', handleMouseDown);
window.addEventListener('mouseup', handleMouseUp);
// 터치 이벤트
container.addEventListener('touchmove', handleTouchMove, { passive: true });
container.addEventListener('touchstart', handleTouchStart, { passive: true });
container.addEventListener('touchend', handleTouchEnd);
container.addEventListener('touchcancel', handleTouchEnd);
return () => {
// 마우스 이벤트 제거
container.removeEventListener('mousemove', handleMouseMove);
container.removeEventListener('mouseenter', handleMouseEnter);
container.removeEventListener('mouseleave', handleMouseLeave);
container.removeEventListener('mousedown', handleMouseDown);
window.removeEventListener('mouseup', handleMouseUp);
// 터치 이벤트 제거
container.removeEventListener('touchmove', handleTouchMove);
container.removeEventListener('touchstart', handleTouchStart);
container.removeEventListener('touchend', handleTouchEnd);
container.removeEventListener('touchcancel', handleTouchEnd);
};
}, [containerRef, handleMouseMove, handleMouseEnter, handleMouseLeave, handleMouseDown, handleMouseUp, handleTouchMove, handleTouchStart, handleTouchEnd]);
/**
*
*/
const getState = useCallback((): MouseState => {
return { ...mouseStateRef.current };
}, []);
return {
getState,
};
};

View File

@ -20,6 +20,14 @@ export type {
AnimationTicker, AnimationTicker,
} from './types'; } from './types';
// 마우스 인터랙션 타입
export type {
SpringPhysicsConfig,
MouseInteractionConfig,
MouseState,
SpringState,
} from './types/interaction';
// 유틸리티 함수 // 유틸리티 함수
export { applyEasing } from './utils/easing'; export { applyEasing } from './utils/easing';
export { SHADER_CONFIG, ANIMATION_CONFIG, DEFAULT_AREA } from './utils/constants'; export { SHADER_CONFIG, ANIMATION_CONFIG, DEFAULT_AREA } from './utils/constants';
@ -28,6 +36,9 @@ export { SHADER_CONFIG, ANIMATION_CONFIG, DEFAULT_AREA } from './utils/constants
export { ThreeScene } from './engine/ThreeScene'; export { ThreeScene } from './engine/ThreeScene';
export { ShaderManager } from './engine/ShaderManager'; export { ShaderManager } from './engine/ShaderManager';
export { AnimationLoop } from './engine/AnimationLoop'; export { AnimationLoop } from './engine/AnimationLoop';
export { SpringPhysics } from './engine/SpringPhysics';
// 훅 // 훅
export { useAnimationFrame } from './hooks/useAnimationFrame'; export { useAnimationFrame } from './hooks/useAnimationFrame';
export { useMouseVelocity } from './hooks/useMouseVelocity';
export { useMouseInteraction } from './hooks/useMouseInteraction';

63
src/types/interaction.ts Normal file
View File

@ -0,0 +1,63 @@
import { Point } from './area';
/**
*
*/
export interface SpringPhysicsConfig {
/** 스프링 탄성 계수 (높을수록 빠르게 복원) */
stiffness: number;
/** 감쇠 계수 (높을수록 빨리 멈춤) */
damping: number;
/** 질량 (높을수록 느리게 움직임) */
mass: number;
/** 영향 반경 (정규화 좌표, 기본값 0.2) */
influenceRadius: number;
/** 최대 왜곡 강도 */
maxStrength: number;
}
/**
*
*/
export interface MouseInteractionConfig {
/** 마우스 인터랙션 활성화 여부 */
enabled: boolean;
/** 스프링 물리 파라미터 */
physics: SpringPhysicsConfig;
/** 최소 속도 임계값 (이보다 느리면 효과 없음) */
minVelocity?: number;
/** 최대 속도 제한 (이보다 빠르면 클램핑) */
maxVelocity?: number;
/** 속도 승수 (마우스 속도에 곱해지는 값) */
velocityMultiplier?: number;
}
/**
*
*/
export interface MouseState {
/** 현재 마우스 위치 (정규화 좌표) */
position: Point | null;
/** 이전 마우스 위치 */
prevPosition: Point | null;
/** 속도 벡터 */
velocity: Point;
/** 가속도 벡터 */
acceleration: Point;
/** 마우스가 컨테이너 위에 있는지 */
isHovering: boolean;
/** 드래그 중인지 */
isDragging: boolean;
}
/**
*
*/
export interface SpringState {
/** 현재 변위 (displacement) */
displacement: Point;
/** 현재 속도 */
velocity: Point;
/** 목표 위치 (평형 상태는 {x:0, y:0}) */
target: Point;
}