Skip to content

Commit

Permalink
🪝🍶 ↝ [SSM-23 SSC-30]: Some thinking & exploring w/ annotations & graphs
Browse files Browse the repository at this point in the history
  • Loading branch information
Gizmotronn committed Nov 23, 2024
1 parent 10e37ac commit 6e83f34
Show file tree
Hide file tree
Showing 5 changed files with 565 additions and 1 deletion.
214 changes: 214 additions & 0 deletions components/Projects/(classifications)/Annotation/Canvas.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import React, { useRef, useState, useEffect } from 'react';
import { useStore } from '@/context/AnnotationStore';
import * as d3 from 'd3';
import { Annotation } from '@/types/Annotation';

export const Canvas = () => {
const svgRef = useRef<SVGSVGElement>(null);
const imgRef = useRef<HTMLImageElement>(null);
const {
image,
annotations,
selectedTool,
selectedAnnotation,
addAnnotation,
updateAnnotation,
presets,
} = useStore();
const [drawing, setDrawing] = useState(false);
const [tempAnnotation, setTempAnnotation] = useState<Partial<Annotation> | null>(
null
);

useEffect(() => {
if (!svgRef.current || !image) return;

const svg = d3.select(svgRef.current);
svg.selectAll('*').remove();

// Create a group for the image
const imageGroup = svg.append('g').attr('class', 'image-layer');

// Add image
imageGroup
.append('image')
.attr('href', image)
.attr('width', '100%')
.attr('height', '100%')
.attr('preserveAspectRatio', 'xMidYMid meet');

// Create a group for annotations
const annotationGroup = svg.append('g').attr('class', 'annotation-layer');

// Render existing annotations
annotations.forEach((ann) => {
const group = annotationGroup.append('g');

switch (ann.type) {
case 'rectangle':
group
.append('rect')
.attr('x', ann.x)
.attr('y', ann.y)
.attr('width', ann.width)
.attr('height', ann.height)
.attr('fill', 'none')
.attr('stroke', ann.color)
.attr('stroke-width', 2)
.attr('data-id', ann.id);
break;
case 'circle':
group
.append('circle')
.attr('cx', ann.x)
.attr('cy', ann.y)
.attr('r', ann.radius)
.attr('fill', 'none')
.attr('stroke', ann.color)
.attr('stroke-width', 2)
.attr('data-id', ann.id);
break;
case 'text':
group
.append('text')
.attr('x', ann.x)
.attr('y', ann.y)
.text(ann.text)
.attr('fill', ann.color)
.attr('data-id', ann.id);
break;
case 'freehand':
group
.append('path')
.attr('d', ann.path)
.attr('fill', 'none')
.attr('stroke', ann.color)
.attr('stroke-width', 2)
.attr('data-id', ann.id);
break;
}

// Add labels
group
.append('text')
.attr('x', ann.x)
.attr('y', ann.y - 10)
.text(ann.label)
.attr('fill', ann.color)
.attr('font-size', '12px');
});

// Render temporary annotation while drawing
if (tempAnnotation && drawing) {
const tempGroup = annotationGroup.append('g').attr('class', 'temp');

switch (tempAnnotation.type) {
case 'rectangle':
tempGroup
.append('rect')
.attr('x', tempAnnotation.x)
.attr('y', tempAnnotation.y)
.attr('width', tempAnnotation.width)
.attr('height', tempAnnotation.height)
.attr('fill', 'none')
.attr('stroke', tempAnnotation.color)
.attr('stroke-width', 2)
.attr('stroke-dasharray', '4');
break;
case 'circle':
tempGroup
.append('circle')
.attr('cx', tempAnnotation.x)
.attr('cy', tempAnnotation.y)
.attr('r', tempAnnotation.radius)
.attr('fill', 'none')
.attr('stroke', tempAnnotation.color)
.attr('stroke-width', 2)
.attr('stroke-dasharray', '4');
break;
case 'freehand':
tempGroup
.append('path')
.attr('d', tempAnnotation.path)
.attr('fill', 'none')
.attr('stroke', tempAnnotation.color)
.attr('stroke-width', 2);
break;
}
}
}, [image, annotations, tempAnnotation, drawing]);

const handleMouseDown = (e: React.MouseEvent) => {
if (!selectedTool || !image) return;

const svg = svgRef.current;
if (!svg) return;

const point = d3.pointer(e.nativeEvent, svg);
const newAnnotation: Partial<Annotation> = {
id: Date.now().toString(),
type: selectedTool,
x: point[0],
y: point[1],
color: `#${Math.floor(Math.random() * 16777215).toString(16)}`, // Fixed color assignment
};

setTempAnnotation(newAnnotation);
setDrawing(true);
};

const handleMouseMove = (e: React.MouseEvent) => {
if (!drawing || !tempAnnotation || !svgRef.current) return;

const point = d3.pointer(e.nativeEvent, svgRef.current);
const updated = { ...tempAnnotation };

switch (tempAnnotation.type) {
case 'rectangle':
updated.width = point[0] - tempAnnotation.x!;
updated.height = point[1] - tempAnnotation.y!;
break;
case 'circle':
const dx = point[0] - tempAnnotation.x!;
const dy = point[1] - tempAnnotation.y!;
updated.radius = Math.sqrt(dx * dx + dy * dy);
break;
case 'freehand':
const path = tempAnnotation.path || `M ${tempAnnotation.x} ${tempAnnotation.y}`;
updated.path = `${path} L ${point[0]} ${point[1]}`;
break;
}

setTempAnnotation(updated);
};

const handleMouseUp = () => {
if (!drawing || !tempAnnotation) return;

const label = prompt('Enter label for this annotation:');
if (label) {
if (tempAnnotation.type === 'text') {
const text = prompt('Enter text:');
if (text) {
addAnnotation({ ...tempAnnotation, text, label } as Annotation);
}
} else {
addAnnotation({ ...tempAnnotation, label } as Annotation);
}
}

setDrawing(false);
setTempAnnotation(null);
};

return (
<svg
ref={svgRef}
className="w-full h-full border border-gray-300 rounded-lg"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
/>
);
};
50 changes: 50 additions & 0 deletions context/AnnotationStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { create } from 'zustand';
import { Annotation, Tool, PresetConfig } from '@/types/Annotation';

interface AnnotationStore {
image: string | null;
annotations: Annotation[];
selectedTool: Tool;
selectedAnnotation: string | null;
presets: PresetConfig[];
setImage: (image: string | null) => void;
addAnnotation: (annotation: Annotation) => void;
updateAnnotation: (id: string, annotation: Partial<Annotation>) => void;
deleteAnnotation: (id: string) => void;
setSelectedTool: (tool: Tool) => void;
setSelectedAnnotation: (id: string | null) => void;
addPreset: (preset: PresetConfig) => void;
deletePreset: (id: string) => void;
}

export const useStore = create<AnnotationStore>((set) => ({
image: null,
annotations: [],
selectedTool: null,
selectedAnnotation: null,
presets: [
{ id: '1', label: 'Cloud', color: '#4299E1', type: 'rectangle' },
{ id: '2', label: 'Bird', color: '#48BB78', type: 'circle' },
],
setImage: (image) => set({ image }),
addAnnotation: (annotation) =>
set((state) => ({ annotations: [...state.annotations, annotation] })),
updateAnnotation: (id, updatedAnnotation) =>
set((state) => ({
annotations: state.annotations.map((ann) =>
ann.id === id ? { ...ann, ...updatedAnnotation } : ann
),
})),
deleteAnnotation: (id) =>
set((state) => ({
annotations: state.annotations.filter((ann) => ann.id !== id),
})),
setSelectedTool: (tool) => set({ selectedTool: tool }),
setSelectedAnnotation: (id) => set({ selectedAnnotation: id }),
addPreset: (preset) =>
set((state) => ({ presets: [...state.presets, preset] })),
deletePreset: (id) =>
set((state) => ({
presets: state.presets.filter((preset) => preset.id !== id),
})),
}));
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,14 @@
"axios": "^1.6.8",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"d3": "^7.9.0",
"daisyui": "^4.11.1",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.3.0",
"eslint": "8.49.0",
"eslint-config-next": "^15.0.0-rc.0",
"framer-motion": "^11.11.17",
"html-to-image": "^1.11.11",
"input-otp": "^1.2.4",
"lodash": "^4.17.21",
"lucide-react": "^0.394.0",
Expand Down Expand Up @@ -81,7 +83,8 @@
"three": "^0.170.0",
"typescript": "^5.6.3",
"use-sound": "^4.0.3",
"vaul": "^0.9.9"
"vaul": "^0.9.9",
"zustand": "^5.0.1"
},
"devDependencies": {
"@types/node": "^22.7.8",
Expand Down
4 changes: 4 additions & 0 deletions types/Annotation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type Annotation = {
id: string;
type: 'rectangle' | 'circle' | 'text'
}
Loading

0 comments on commit 6e83f34

Please sign in to comment.