Feat: 캔버스 직사각형 렌더링 추가 - S11P21S002-103
This commit is contained in:
parent
ee85f6fdb2
commit
f7cc913e14
104
frontend/package-lock.json
generated
104
frontend/package-lock.json
generated
@ -18,14 +18,17 @@
|
||||
"axios": "^1.7.5",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"konva": "^9.3.14",
|
||||
"lucide-react": "^0.436.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-konva": "^18.2.10",
|
||||
"react-resizable-panels": "^2.1.1",
|
||||
"react-router-dom": "^6.26.1",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-image": "^1.1.1",
|
||||
"zod": "^3.23.8",
|
||||
"zustand": "^4.5.5"
|
||||
},
|
||||
@ -5590,7 +5593,6 @@
|
||||
"version": "15.7.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz",
|
||||
"integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
@ -5609,7 +5611,6 @@
|
||||
"version": "18.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.4.tgz",
|
||||
"integrity": "sha512-J7W30FTdfCxDDjmfRM+/JqLHBIyl7xUIp9kwK637FGmY7+mkSFSe6L4jpZzhj5QMfLssSDP4/i75AKkrdC7/Jw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
@ -5626,6 +5627,15 @@
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-reconciler": {
|
||||
"version": "0.28.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.8.tgz",
|
||||
"integrity": "sha512-SN9c4kxXZonFhbX4hJrZy37yw9e7EIxcpHCxQv5JUS18wDE5ovkQKlqQEkufdJCCMfuI9BnjUJvhYeJ9x5Ra7g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/resolve": {
|
||||
"version": "1.20.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz",
|
||||
@ -6999,7 +7009,6 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
@ -8924,6 +8933,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/its-fine": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz",
|
||||
"integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react-reconciler": "^0.28.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jackspeak": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||
@ -9098,6 +9119,26 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/konva": {
|
||||
"version": "9.3.14",
|
||||
"resolved": "https://registry.npmjs.org/konva/-/konva-9.3.14.tgz",
|
||||
"integrity": "sha512-Gmm5lyikGYJyogKQA7Fy6dKkfNh350V6DwfZkid0RVrGYP2cfCsxuMxgF5etKeCv7NjXYpJxKqi1dYkIkX/dcA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/lavrton"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/konva"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/lavrton"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/leven": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||
@ -10642,6 +10683,53 @@
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/react-konva": {
|
||||
"version": "18.2.10",
|
||||
"resolved": "https://registry.npmjs.org/react-konva/-/react-konva-18.2.10.tgz",
|
||||
"integrity": "sha512-ohcX1BJINL43m4ynjZ24MxFI1syjBdrXhqVxYVDw2rKgr3yuS0x/6m1Y2Z4sl4T/gKhfreBx8KHisd0XC6OT1g==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/lavrton"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/konva"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/lavrton"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react-reconciler": "^0.28.2",
|
||||
"its-fine": "^1.1.1",
|
||||
"react-reconciler": "~0.29.0",
|
||||
"scheduler": "^0.23.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"konva": "^8.0.1 || ^7.2.5 || ^9.0.0",
|
||||
"react": ">=18.0.0",
|
||||
"react-dom": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-reconciler": {
|
||||
"version": "0.29.2",
|
||||
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz",
|
||||
"integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.5.7",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.7.tgz",
|
||||
@ -12479,6 +12567,16 @@
|
||||
"integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/use-image": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/use-image/-/use-image-1.1.1.tgz",
|
||||
"integrity": "sha512-n4YO2k8AJG/BcDtxmBx8Aa+47kxY5m335dJiCQA5tTeVU4XdhrhqR6wT0WISRXwdMEOv5CSjqekDZkEMiiWaYQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sidecar": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz",
|
||||
|
@ -24,14 +24,17 @@
|
||||
"axios": "^1.7.5",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"konva": "^9.3.14",
|
||||
"lucide-react": "^0.436.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-konva": "^18.2.10",
|
||||
"react-resizable-panels": "^2.1.1",
|
||||
"react-router-dom": "^6.26.1",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-image": "^1.1.1",
|
||||
"zod": "^3.23.8",
|
||||
"zustand": "^4.5.5"
|
||||
},
|
||||
|
BIN
frontend/public/sample.jpg
Normal file
BIN
frontend/public/sample.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 918 KiB |
40
frontend/src/components/ImageCanvas/LabelRect.tsx
Normal file
40
frontend/src/components/ImageCanvas/LabelRect.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { Label } from '@/types';
|
||||
import Konva from 'konva';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Line } from 'react-konva';
|
||||
|
||||
export default function LabelRect({
|
||||
isSelected,
|
||||
onSelect,
|
||||
info,
|
||||
}: {
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
info: Label;
|
||||
}) {
|
||||
const rectRef = useRef<Konva.Line>(null);
|
||||
const trRef = useRef<Konva.Transformer>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelected) {
|
||||
trRef.current?.nodes([rectRef.current as Konva.Node]);
|
||||
trRef.current?.getLayer()?.batchDraw();
|
||||
}
|
||||
}, [isSelected]);
|
||||
|
||||
return (
|
||||
<Line
|
||||
points={info.coordinates.flat()}
|
||||
stroke={info.color}
|
||||
strokeWidth={1}
|
||||
ref={rectRef}
|
||||
onMouseDown={onSelect}
|
||||
onTouchStart={onSelect}
|
||||
strokeScaleEnabled={false}
|
||||
fillAfterStrokeEnabled={false}
|
||||
fill={`${info.color}33`}
|
||||
closed
|
||||
draggable
|
||||
/>
|
||||
);
|
||||
}
|
14
frontend/src/components/ImageCanvas/index.stories.tsx
Normal file
14
frontend/src/components/ImageCanvas/index.stories.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { Meta } from '@storybook/react';
|
||||
import ImageCanvas from '.';
|
||||
|
||||
const meta: Meta<typeof ImageCanvas> = {
|
||||
title: 'Components/ImageCanvas',
|
||||
component: ImageCanvas,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = () => <ImageCanvas />;
|
145
frontend/src/components/ImageCanvas/index.tsx
Normal file
145
frontend/src/components/ImageCanvas/index.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
import useCanvasStore from '@/stores/useCanvasStore';
|
||||
import Konva from 'konva';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Image, Layer, Stage } from 'react-konva';
|
||||
import useImage from 'use-image';
|
||||
import { Label } from '@/types';
|
||||
import LabelRect from './LabelRect';
|
||||
import { Vector2d } from 'konva/lib/types';
|
||||
|
||||
const mockLabels: Label[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Label 1',
|
||||
color: '#FFaa33',
|
||||
type: 'rect',
|
||||
coordinates: [
|
||||
[100, 100],
|
||||
[200, 100],
|
||||
[200, 200],
|
||||
[100, 200],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Label 3',
|
||||
color: '#aa33ff',
|
||||
type: 'rect',
|
||||
coordinates: [
|
||||
[1200, 200],
|
||||
[1200, 400],
|
||||
[1400, 400],
|
||||
[1400, 200],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Label 3',
|
||||
color: '#aaff33',
|
||||
type: 'polygon',
|
||||
// star shape
|
||||
coordinates: [
|
||||
[500, 375],
|
||||
[523, 232],
|
||||
[600, 232],
|
||||
[535, 175],
|
||||
[560, 100],
|
||||
[500, 150],
|
||||
[440, 100],
|
||||
[465, 175],
|
||||
[400, 232],
|
||||
[477, 232],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function ImageCanvas() {
|
||||
const stageWidth = window.innerWidth;
|
||||
const stageHeight = window.innerHeight;
|
||||
const stageRef = useRef<Konva.Stage>(null);
|
||||
const scale = useRef<number>(0);
|
||||
const imageUrl = '/sample.jpg';
|
||||
const labels = useCanvasStore((state) => state.labels) ?? [];
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [image, imageStatus] = useImage(imageUrl);
|
||||
const handleZoom = (e: Konva.KonvaEventObject<WheelEvent>) => {
|
||||
const scaleBy = 1.05;
|
||||
const oldScale = scale.current;
|
||||
const mousePointTo = {
|
||||
x: (stageRef.current?.getPointerPosition()?.x ?? 0) / oldScale - stageRef.current!.x() / oldScale,
|
||||
y: (stageRef.current?.getPointerPosition()?.y ?? 0) / oldScale - stageRef.current!.y() / oldScale,
|
||||
};
|
||||
const newScale = e.evt.deltaY < 0 ? oldScale * scaleBy : oldScale / scaleBy;
|
||||
scale.current = newScale;
|
||||
|
||||
stageRef.current?.scale({ x: newScale, y: newScale });
|
||||
const newPos = {
|
||||
x: -(mousePointTo.x - (stageRef.current?.getPointerPosition()?.x ?? 0) / newScale) * newScale,
|
||||
y: -(mousePointTo.y - (stageRef.current?.getPointerPosition()?.y ?? 0) / newScale) * newScale,
|
||||
};
|
||||
stageRef.current?.position(newPos);
|
||||
stageRef.current?.batchDraw();
|
||||
};
|
||||
const handleScroll = (e: Konva.KonvaEventObject<WheelEvent>) => {
|
||||
const delta = -e.evt.deltaY;
|
||||
const x = stageRef.current?.x();
|
||||
const y = stageRef.current?.y();
|
||||
const newX = e.evt.shiftKey ? x! + delta : x!;
|
||||
const newY = e.evt.shiftKey ? y! : y! + delta;
|
||||
stageRef.current?.position({ x: newX, y: newY });
|
||||
stageRef.current?.batchDraw();
|
||||
};
|
||||
|
||||
const handleWheel = (e: Konva.KonvaEventObject<WheelEvent>) => {
|
||||
if (stageRef.current === null) return;
|
||||
e.evt.preventDefault();
|
||||
|
||||
e.evt.ctrlKey ? handleZoom(e) : handleScroll(e);
|
||||
};
|
||||
const getScale = (): Vector2d => {
|
||||
if (scale.current) return { x: scale.current, y: scale.current };
|
||||
const widthRatio = stageWidth / image!.width;
|
||||
const heightRatio = stageHeight / image!.height;
|
||||
scale.current = Math.min(widthRatio, heightRatio);
|
||||
|
||||
console.log(scale);
|
||||
|
||||
return { x: scale.current, y: scale.current };
|
||||
};
|
||||
|
||||
// TODO: remove mock data
|
||||
useEffect(() => {
|
||||
useCanvasStore.setState({ labels: mockLabels });
|
||||
}, []);
|
||||
|
||||
return imageStatus === 'loaded' ? (
|
||||
<Stage
|
||||
ref={stageRef}
|
||||
width={stageWidth}
|
||||
height={stageHeight}
|
||||
className="overflow-hidden bg-gray-200"
|
||||
draggable
|
||||
onWheel={handleWheel}
|
||||
scale={getScale()}
|
||||
>
|
||||
<Layer>
|
||||
<Image image={image} />
|
||||
</Layer>
|
||||
{labels.map((label) => (
|
||||
<Layer key={label.id}>
|
||||
{label.type === 'rect' ? (
|
||||
<LabelRect
|
||||
isSelected={label.id === selectedId}
|
||||
onSelect={() => setSelectedId(label.id)}
|
||||
info={label}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Layer>
|
||||
))}
|
||||
</Stage>
|
||||
) : (
|
||||
<div></div>
|
||||
);
|
||||
}
|
@ -65,16 +65,22 @@ export default function WorkspaceLayout() {
|
||||
id: 1,
|
||||
name: 'Label 1',
|
||||
color: '#FFaa33',
|
||||
coordinates: [],
|
||||
type: 'rect',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Label 2',
|
||||
color: '#aaFF55',
|
||||
coordinates: [],
|
||||
type: 'rect',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Label 3',
|
||||
color: '#77aaFF',
|
||||
coordinates: [],
|
||||
type: 'rect',
|
||||
},
|
||||
];
|
||||
|
||||
@ -88,7 +94,7 @@ export default function WorkspaceLayout() {
|
||||
projects={workspace.projects}
|
||||
/>
|
||||
<ResizablePanel className="flex w-full items-center">
|
||||
<main className="grow">
|
||||
<main className="h-full grow">
|
||||
<Outlet />
|
||||
</main>
|
||||
<WorkspaceLabelBar labels={labels} />
|
||||
|
22
frontend/src/stores/useCanvasStore.ts
Normal file
22
frontend/src/stores/useCanvasStore.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Label } from '@/types';
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface CanvasState {
|
||||
image: string;
|
||||
labels: Label[];
|
||||
changeImage: (image: string, labels: Label[]) => void;
|
||||
addLabel: (label: Label) => void;
|
||||
removeLabel: (labelId: number) => void;
|
||||
updateLabel: (label: Label) => void;
|
||||
}
|
||||
|
||||
const useCanvasStore = create<CanvasState>()((set) => ({
|
||||
image: '',
|
||||
labels: [],
|
||||
changeImage: (image: string, labels: Label[]) => set({ image, labels }),
|
||||
addLabel: (label: Label) => set((state) => ({ labels: [...state.labels, label] })),
|
||||
removeLabel: (labelId: number) => set((state) => ({ labels: state.labels.filter((label) => label.id !== labelId) })),
|
||||
updateLabel: (label: Label) => set((state) => ({ labels: state.labels.map((l) => (l.id === label.id ? label : l)) })),
|
||||
}));
|
||||
|
||||
export default useCanvasStore;
|
@ -30,4 +30,6 @@ export type Label = {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
type: 'polygon' | 'rect';
|
||||
coordinates: Array<[number, number]>;
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user