Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FE][Feat] #4 : 캔버스에 선그리기, 이동, 확대/축소 훅 #205

Merged
merged 9 commits into from
Nov 19, 2024
137 changes: 68 additions & 69 deletions frontend/src/component/linedrawer/Linedrawer.tsx
Original file line number Diff line number Diff line change
@@ -1,85 +1,84 @@
import React, { useRef, useState, useEffect } from 'react';
import React, { useRef } from 'react';
import { useDrawing } from '@/hooks/useDrawing';
import { usePanning } from '@/hooks/usePanning';
import { useZoom } from '@/hooks/useZoom';
import { MdArrowCircleLeft, MdArrowCircleRight } from 'react-icons/md';
import { useUndoRedo } from '@/hooks/useUndoRedo';

interface IPoint {
x: number;
y: number;
}
// 네이버지도 기준 확대/축소 비율 단계
const NAVER_STEP_SCALES = [
100, 50, 30, 20, 10, 5, 3, 1, 0.5, 0.3, 0.1, 0.05, 0.03, 0.02, 0.01, 0.005,
];
// 선의 굵기 상수
const LINE_WIDTH = 2;
// 선의 색 상수
const STROKE_STYLE = 'black';
// 지도의 처음 확대/축소 비율 단계 index
const INITIAL_ZOOM_INDEX = 7;

interface ILinedrawerProps {
width?: number | string;
height?: number | string;
}

export const Linedrawer: React.FC<ILinedrawerProps> = () => {
export const Linedrawer = () => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [points, setPoints] = useState<IPoint[]>([]);
const [previewPoint, setPreviewPoint] = useState<IPoint | null>(null);

useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;

const context = canvas.getContext('2d');
if (!context) return;

context.clearRect(0, 0, canvas.width, canvas.height);
context.lineWidth = 2;
context.strokeStyle = 'black';

if (points.length > 1) {
context.beginPath();
context.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i += 1) {
context.lineTo(points[i].x, points[i].y);
}
context.stroke();
}

if (points.length > 0 && previewPoint) {
context.beginPath();
context.moveTo(points[points.length - 1].x, points[points.length - 1].y);
context.lineTo(previewPoint.x, previewPoint.y);
context.strokeStyle = 'rgba(0, 0, 0, 0.5)';
context.stroke();
}
}, [points, previewPoint]);

const handleCanvasClick = (event: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;

const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const newPoint = { x, y };

setPoints([...points, newPoint]);
};
const { points, addPoint, undo, redo, undoStack, redoStack } = useUndoRedo([]);

const handleMouseMove = (event: React.MouseEvent<HTMLCanvasElement>) => {
if (points.length === 0) return;
const { draw, scaleRef, viewPosRef } = useDrawing({
canvasRef,
points,
lineWidth: LINE_WIDTH,
strokeStyle: STROKE_STYLE,
initialScale: NAVER_STEP_SCALES[INITIAL_ZOOM_INDEX],
});
const { handleMouseMove, handleMouseDown, handleMouseUp } = usePanning({ viewPosRef, draw });
const { handleWheel } = useZoom({
scaleRef,
viewPosRef,
draw,
stepScales: NAVER_STEP_SCALES,
initialZoomIndex: INITIAL_ZOOM_INDEX,
});

const canvas = canvasRef.current;
if (!canvas) return;
const handleCanvasClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;

const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
setPreviewPoint({ x, y });
const x = (e.clientX - rect.left - viewPosRef.current.x) / scaleRef.current;
const y = (e.clientY - rect.top - viewPosRef.current.y) / scaleRef.current;
addPoint({ x, y });
};

return (
<div className="z-11 absolute">
<div className="relative h-[600px] w-[800px]">
<div className="absolute left-1/2 top-[10px] z-10 flex -translate-x-1/2 transform gap-2">
<button
type="button"
onClick={undo}
disabled={undoStack.length === 0}
className={`h-[35px] w-[35px] ${
undoStack.length === 0 ? 'cursor-not-allowed opacity-50' : ''
}`}
>
<MdArrowCircleLeft size={24} />
</button>
<button
type="button"
onClick={redo}
disabled={redoStack.length === 0}
className={`h-[35px] w-[35px] ${
redoStack.length === 0 ? 'cursor-not-allowed opacity-50' : ''
}`}
>
<MdArrowCircleRight size={24} />
</button>
</div>
<canvas
ref={canvasRef}
// TODO : canvas의 넓이 높이 지도에 맞게 조절
width={typeof width === 'number' ? width : '430'}
height={typeof height === 'number' ? height : '862'}
width="800"
height="600"
className="cursor-crosshair border border-gray-300"
onClick={handleCanvasClick}
onMouseMove={handleMouseMove}
className="border border-gray-300"
style={{ backgroundColor: 'transparent' }}
data-testid="canvas"
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onWheel={handleWheel}
/>
</div>
);
Expand Down
72 changes: 72 additions & 0 deletions frontend/src/hooks/useDrawing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { useRef, useEffect } from 'react';

interface IPoint {
x: number;
y: number;
}

interface IUseDrawingProps {
canvasRef: React.RefObject<HTMLCanvasElement>;
points: IPoint[];
lineWidth: number;
strokeStyle: string;
initialScale: number;
}

const INITIAL_POSITION = { x: 0, y: 0 };

export const useDrawing = (props: IUseDrawingProps) => {
const scaleRef = useRef(props.initialScale);
const viewPosRef = useRef(INITIAL_POSITION);

const getCanvasContext = (): CanvasRenderingContext2D | null =>
props.canvasRef.current?.getContext('2d') || null;

const setTransform = (context: CanvasRenderingContext2D) => {
context.setTransform(
scaleRef.current,
0,
0,
scaleRef.current,
viewPosRef.current.x,
viewPosRef.current.y,
);
};

const draw = () => {
const context = getCanvasContext();
if (!context) return;

const canvas = props.canvasRef.current;
if (!canvas) return;

context.clearRect(0, 0, canvas.width, canvas.height);
setTransform(context);

if (props.points.length > 1) {
context.lineWidth = props.lineWidth / scaleRef.current;
context.strokeStyle = props.strokeStyle;
context.beginPath();
context.moveTo(props.points[0].x, props.points[0].y);
props.points.slice(1).forEach(point => context.lineTo(point.x, point.y));
context.stroke();
} else if (props.points.length === 1) {
context.fillStyle = props.strokeStyle;
context.beginPath();
context.arc(
props.points[0].x,
props.points[0].y,
(props.lineWidth + 1) / scaleRef.current,
0,
2 * Math.PI,
);
context.fill();
}
};

useEffect(() => {
draw();
}, [props.points]);

return { draw, scaleRef, viewPosRef };
};
42 changes: 42 additions & 0 deletions frontend/src/hooks/usePanning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useRef } from 'react';

interface IUsePanningProps {
viewPosRef: React.MutableRefObject<{ x: number; y: number }>;
draw: () => void;
}

const INITIAL_POSITION = { x: 0, y: 0 };

export const usePanning = (props: IUsePanningProps) => {
const panningRef = useRef(false);
const startPosRef = useRef(INITIAL_POSITION);

const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!panningRef.current || !props.viewPosRef.current) return;

const viewPos = props.viewPosRef.current;
const { x, y } = startPosRef.current;

viewPos.x = e.clientX - x;
viewPos.y = e.clientY - y;

props.draw();
};

const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!props.viewPosRef.current) return;

const viewPos = props.viewPosRef.current;
startPosRef.current = {
x: e.clientX - viewPos.x,
y: e.clientY - viewPos.y,
};
panningRef.current = true;
};

const handleMouseUp = () => {
panningRef.current = false;
};

return { handleMouseMove, handleMouseDown, handleMouseUp };
};
34 changes: 34 additions & 0 deletions frontend/src/hooks/useUndoRedo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useState } from 'react';

interface IPoint {
x: number;
y: number;
}

export const useUndoRedo = (initialPoints: IPoint[]) => {
const [points, setPoints] = useState<IPoint[]>(initialPoints);
const [undoStack, setUndoStack] = useState<IPoint[][]>([]);
const [redoStack, setRedoStack] = useState<IPoint[][]>([]);

const addPoint = (newPoint: IPoint) => {
setUndoStack(prev => [...prev, points]);
setPoints(prevPoints => [...prevPoints, newPoint]);
setRedoStack([]);
};

const undo = () => {
if (undoStack.length === 0) return;
setRedoStack(prev => [points, ...prev]);
setPoints(undoStack[undoStack.length - 1]);
setUndoStack(undoStack.slice(0, -1));
};

const redo = () => {
if (redoStack.length === 0) return;
setUndoStack(prev => [...prev, points]);
setPoints(redoStack[0]);
setRedoStack(redoStack.slice(1));
};

return { points, addPoint, undo, redo, undoStack, redoStack };
};
46 changes: 46 additions & 0 deletions frontend/src/hooks/useZoom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useRef } from 'react';

interface IUseZoomProps {
scaleRef: React.MutableRefObject<number>;
viewPosRef: React.MutableRefObject<{ x: number; y: number }>;
draw: () => void;
stepScales: number[];
initialZoomIndex: number;
}

export const useZoom = (props: IUseZoomProps) => {
const zoomIndexRef = useRef(props.initialZoomIndex);
const MIN_SCALE_INDEX = 0;
const MAX_SCALE_INDEX = props.stepScales.length - 1;

const handleWheel = (e: React.WheelEvent<HTMLCanvasElement>) => {
const viewPos = props.viewPosRef;
const scale = props.scaleRef;

if (!viewPos || scale.current === null) return;

e.preventDefault();
const { offsetX, offsetY } = e.nativeEvent;

if (e.deltaY < 0 && zoomIndexRef.current > MIN_SCALE_INDEX) {
zoomIndexRef.current -= 1;
} else if (e.deltaY > 0 && zoomIndexRef.current < MAX_SCALE_INDEX) {
zoomIndexRef.current += 1;
}

const newScale =
props.stepScales[props.initialZoomIndex] / props.stepScales[zoomIndexRef.current];
const xs = (offsetX - viewPos.current.x) / scale.current;
const ys = (offsetY - viewPos.current.y) / scale.current;

scale.current = newScale;
viewPos.current = {
x: offsetX - xs * scale.current,
y: offsetY - ys * scale.current,
};

props.draw();
};

return { handleWheel, zoomIndexRef };
};
Loading