Skip to content

Commit 6e83f34

Browse files
committed
🪝🍶 ↝ [SSM-23 SSC-30]: Some thinking & exploring w/ annotations & graphs
1 parent 10e37ac commit 6e83f34

File tree

5 files changed

+565
-1
lines changed

5 files changed

+565
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import React, { useRef, useState, useEffect } from 'react';
2+
import { useStore } from '@/context/AnnotationStore';
3+
import * as d3 from 'd3';
4+
import { Annotation } from '@/types/Annotation';
5+
6+
export const Canvas = () => {
7+
const svgRef = useRef<SVGSVGElement>(null);
8+
const imgRef = useRef<HTMLImageElement>(null);
9+
const {
10+
image,
11+
annotations,
12+
selectedTool,
13+
selectedAnnotation,
14+
addAnnotation,
15+
updateAnnotation,
16+
presets,
17+
} = useStore();
18+
const [drawing, setDrawing] = useState(false);
19+
const [tempAnnotation, setTempAnnotation] = useState<Partial<Annotation> | null>(
20+
null
21+
);
22+
23+
useEffect(() => {
24+
if (!svgRef.current || !image) return;
25+
26+
const svg = d3.select(svgRef.current);
27+
svg.selectAll('*').remove();
28+
29+
// Create a group for the image
30+
const imageGroup = svg.append('g').attr('class', 'image-layer');
31+
32+
// Add image
33+
imageGroup
34+
.append('image')
35+
.attr('href', image)
36+
.attr('width', '100%')
37+
.attr('height', '100%')
38+
.attr('preserveAspectRatio', 'xMidYMid meet');
39+
40+
// Create a group for annotations
41+
const annotationGroup = svg.append('g').attr('class', 'annotation-layer');
42+
43+
// Render existing annotations
44+
annotations.forEach((ann) => {
45+
const group = annotationGroup.append('g');
46+
47+
switch (ann.type) {
48+
case 'rectangle':
49+
group
50+
.append('rect')
51+
.attr('x', ann.x)
52+
.attr('y', ann.y)
53+
.attr('width', ann.width)
54+
.attr('height', ann.height)
55+
.attr('fill', 'none')
56+
.attr('stroke', ann.color)
57+
.attr('stroke-width', 2)
58+
.attr('data-id', ann.id);
59+
break;
60+
case 'circle':
61+
group
62+
.append('circle')
63+
.attr('cx', ann.x)
64+
.attr('cy', ann.y)
65+
.attr('r', ann.radius)
66+
.attr('fill', 'none')
67+
.attr('stroke', ann.color)
68+
.attr('stroke-width', 2)
69+
.attr('data-id', ann.id);
70+
break;
71+
case 'text':
72+
group
73+
.append('text')
74+
.attr('x', ann.x)
75+
.attr('y', ann.y)
76+
.text(ann.text)
77+
.attr('fill', ann.color)
78+
.attr('data-id', ann.id);
79+
break;
80+
case 'freehand':
81+
group
82+
.append('path')
83+
.attr('d', ann.path)
84+
.attr('fill', 'none')
85+
.attr('stroke', ann.color)
86+
.attr('stroke-width', 2)
87+
.attr('data-id', ann.id);
88+
break;
89+
}
90+
91+
// Add labels
92+
group
93+
.append('text')
94+
.attr('x', ann.x)
95+
.attr('y', ann.y - 10)
96+
.text(ann.label)
97+
.attr('fill', ann.color)
98+
.attr('font-size', '12px');
99+
});
100+
101+
// Render temporary annotation while drawing
102+
if (tempAnnotation && drawing) {
103+
const tempGroup = annotationGroup.append('g').attr('class', 'temp');
104+
105+
switch (tempAnnotation.type) {
106+
case 'rectangle':
107+
tempGroup
108+
.append('rect')
109+
.attr('x', tempAnnotation.x)
110+
.attr('y', tempAnnotation.y)
111+
.attr('width', tempAnnotation.width)
112+
.attr('height', tempAnnotation.height)
113+
.attr('fill', 'none')
114+
.attr('stroke', tempAnnotation.color)
115+
.attr('stroke-width', 2)
116+
.attr('stroke-dasharray', '4');
117+
break;
118+
case 'circle':
119+
tempGroup
120+
.append('circle')
121+
.attr('cx', tempAnnotation.x)
122+
.attr('cy', tempAnnotation.y)
123+
.attr('r', tempAnnotation.radius)
124+
.attr('fill', 'none')
125+
.attr('stroke', tempAnnotation.color)
126+
.attr('stroke-width', 2)
127+
.attr('stroke-dasharray', '4');
128+
break;
129+
case 'freehand':
130+
tempGroup
131+
.append('path')
132+
.attr('d', tempAnnotation.path)
133+
.attr('fill', 'none')
134+
.attr('stroke', tempAnnotation.color)
135+
.attr('stroke-width', 2);
136+
break;
137+
}
138+
}
139+
}, [image, annotations, tempAnnotation, drawing]);
140+
141+
const handleMouseDown = (e: React.MouseEvent) => {
142+
if (!selectedTool || !image) return;
143+
144+
const svg = svgRef.current;
145+
if (!svg) return;
146+
147+
const point = d3.pointer(e.nativeEvent, svg);
148+
const newAnnotation: Partial<Annotation> = {
149+
id: Date.now().toString(),
150+
type: selectedTool,
151+
x: point[0],
152+
y: point[1],
153+
color: `#${Math.floor(Math.random() * 16777215).toString(16)}`, // Fixed color assignment
154+
};
155+
156+
setTempAnnotation(newAnnotation);
157+
setDrawing(true);
158+
};
159+
160+
const handleMouseMove = (e: React.MouseEvent) => {
161+
if (!drawing || !tempAnnotation || !svgRef.current) return;
162+
163+
const point = d3.pointer(e.nativeEvent, svgRef.current);
164+
const updated = { ...tempAnnotation };
165+
166+
switch (tempAnnotation.type) {
167+
case 'rectangle':
168+
updated.width = point[0] - tempAnnotation.x!;
169+
updated.height = point[1] - tempAnnotation.y!;
170+
break;
171+
case 'circle':
172+
const dx = point[0] - tempAnnotation.x!;
173+
const dy = point[1] - tempAnnotation.y!;
174+
updated.radius = Math.sqrt(dx * dx + dy * dy);
175+
break;
176+
case 'freehand':
177+
const path = tempAnnotation.path || `M ${tempAnnotation.x} ${tempAnnotation.y}`;
178+
updated.path = `${path} L ${point[0]} ${point[1]}`;
179+
break;
180+
}
181+
182+
setTempAnnotation(updated);
183+
};
184+
185+
const handleMouseUp = () => {
186+
if (!drawing || !tempAnnotation) return;
187+
188+
const label = prompt('Enter label for this annotation:');
189+
if (label) {
190+
if (tempAnnotation.type === 'text') {
191+
const text = prompt('Enter text:');
192+
if (text) {
193+
addAnnotation({ ...tempAnnotation, text, label } as Annotation);
194+
}
195+
} else {
196+
addAnnotation({ ...tempAnnotation, label } as Annotation);
197+
}
198+
}
199+
200+
setDrawing(false);
201+
setTempAnnotation(null);
202+
};
203+
204+
return (
205+
<svg
206+
ref={svgRef}
207+
className="w-full h-full border border-gray-300 rounded-lg"
208+
onMouseDown={handleMouseDown}
209+
onMouseMove={handleMouseMove}
210+
onMouseUp={handleMouseUp}
211+
onMouseLeave={handleMouseUp}
212+
/>
213+
);
214+
};

context/AnnotationStore.ts

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { create } from 'zustand';
2+
import { Annotation, Tool, PresetConfig } from '@/types/Annotation';
3+
4+
interface AnnotationStore {
5+
image: string | null;
6+
annotations: Annotation[];
7+
selectedTool: Tool;
8+
selectedAnnotation: string | null;
9+
presets: PresetConfig[];
10+
setImage: (image: string | null) => void;
11+
addAnnotation: (annotation: Annotation) => void;
12+
updateAnnotation: (id: string, annotation: Partial<Annotation>) => void;
13+
deleteAnnotation: (id: string) => void;
14+
setSelectedTool: (tool: Tool) => void;
15+
setSelectedAnnotation: (id: string | null) => void;
16+
addPreset: (preset: PresetConfig) => void;
17+
deletePreset: (id: string) => void;
18+
}
19+
20+
export const useStore = create<AnnotationStore>((set) => ({
21+
image: null,
22+
annotations: [],
23+
selectedTool: null,
24+
selectedAnnotation: null,
25+
presets: [
26+
{ id: '1', label: 'Cloud', color: '#4299E1', type: 'rectangle' },
27+
{ id: '2', label: 'Bird', color: '#48BB78', type: 'circle' },
28+
],
29+
setImage: (image) => set({ image }),
30+
addAnnotation: (annotation) =>
31+
set((state) => ({ annotations: [...state.annotations, annotation] })),
32+
updateAnnotation: (id, updatedAnnotation) =>
33+
set((state) => ({
34+
annotations: state.annotations.map((ann) =>
35+
ann.id === id ? { ...ann, ...updatedAnnotation } : ann
36+
),
37+
})),
38+
deleteAnnotation: (id) =>
39+
set((state) => ({
40+
annotations: state.annotations.filter((ann) => ann.id !== id),
41+
})),
42+
setSelectedTool: (tool) => set({ selectedTool: tool }),
43+
setSelectedAnnotation: (id) => set({ selectedAnnotation: id }),
44+
addPreset: (preset) =>
45+
set((state) => ({ presets: [...state.presets, preset] })),
46+
deletePreset: (id) =>
47+
set((state) => ({
48+
presets: state.presets.filter((preset) => preset.id !== id),
49+
})),
50+
}));

package.json

+4-1
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,14 @@
4545
"axios": "^1.6.8",
4646
"class-variance-authority": "^0.7.0",
4747
"clsx": "^2.1.1",
48+
"d3": "^7.9.0",
4849
"daisyui": "^4.11.1",
4950
"date-fns": "^3.6.0",
5051
"embla-carousel-react": "^8.3.0",
5152
"eslint": "8.49.0",
5253
"eslint-config-next": "^15.0.0-rc.0",
5354
"framer-motion": "^11.11.17",
55+
"html-to-image": "^1.11.11",
5456
"input-otp": "^1.2.4",
5557
"lodash": "^4.17.21",
5658
"lucide-react": "^0.394.0",
@@ -81,7 +83,8 @@
8183
"three": "^0.170.0",
8284
"typescript": "^5.6.3",
8385
"use-sound": "^4.0.3",
84-
"vaul": "^0.9.9"
86+
"vaul": "^0.9.9",
87+
"zustand": "^5.0.1"
8588
},
8689
"devDependencies": {
8790
"@types/node": "^22.7.8",

types/Annotation.tsx

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export type Annotation = {
2+
id: string;
3+
type: 'rectangle' | 'circle' | 'text'
4+
}

0 commit comments

Comments
 (0)