diff --git a/app/starnet/annotation/page.tsx b/app/starnet/annotation/page.tsx deleted file mode 100644 index 8d391d2b..00000000 --- a/app/starnet/annotation/page.tsx +++ /dev/null @@ -1,152 +0,0 @@ -'use client'; - -import React, { useState, useRef } from 'react'; -import Canvas from '@/components/Projects/(classifications)/Annotation/Canvas'; -import Toolbar from '@/components/Projects/(classifications)/Annotation/Toolbar'; -import AnnotationList from '@/components/Projects/(classifications)/Annotation/AnnotationList'; -import { Annotation } from '@/types/Annotation'; -import { Upload } from 'lucide-react'; -import dynamic from 'next/dynamic'; - -function AnnotationCreate() { - const [imageUrl, setImageUrl] = useState(''); - const [currentTool, setCurrentTool] = useState(''); - const [isDrawing, setIsDrawing] = useState(false); - const [annotations, setAnnotations] = useState([]); - const containerRef = useRef(null); - const canvasRef = useRef(null); - - const handleFileUpload = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (file) { - const url = URL.createObjectURL(file); - setImageUrl(url); - } - }; - - const handleAddAnnotation = (annotation: Annotation) => { - setAnnotations([...annotations, annotation]); - }; - - const handleLabelChange = (index: number, newLabel: string) => { - const newAnnotations = [...annotations]; - newAnnotations[index].label = newLabel; - setAnnotations(newAnnotations); - }; - - const handleDownload = () => { - if (!canvasRef.current) return; - - const canvas = canvasRef.current; - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - // Draw the image onto the canvas - const img = new Image(); - img.src = imageUrl; - img.onload = () => { - // Clear the canvas before drawing - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.drawImage(img, 0, 0); - - // Now draw the annotations (example, adjust based on how you manage annotations) - annotations.forEach((annotation) => { - const x = annotation.x ?? 0; - const y = annotation.y ?? 0; - const width = annotation.width ?? 0; - const height = annotation.height ?? 0; - - ctx.beginPath(); - ctx.rect(x, y, width, height); // Safe to call now - ctx.strokeStyle = 'red'; - ctx.lineWidth = 2; - ctx.stroke(); - ctx.fillStyle = 'red'; - ctx.fillText(annotation.label, x, y - 5); // Add label with default x and y - }); - - // Generate the data URL and trigger download - const dataUrl = canvas.toDataURL('image/png'); - const link = document.createElement('a'); - link.download = 'annotated-image.png'; - link.href = dataUrl; - link.click(); - }; - }; - - const handleShare = async () => { - if (!canvasRef.current) return; - - const canvas = canvasRef.current; - const dataUrl = canvas.toDataURL('image/png'); - const blob = await (await fetch(dataUrl)).blob(); - const file = new File([blob], 'annotated-image.png', { type: 'image/png' }); - - if (navigator.share) { - await navigator.share({ - files: [file], - title: 'Annotated Image', - }); - } else { - alert('Web Share API is not supported in your browser'); - } - }; - - return ( -
-
-

Image Annotator

- -
-
- {!imageUrl ? ( - - ) : ( -
- -
- {/* */} -
- {/* Create a hidden canvas to draw the image and annotations */} - -
- )} -
- - -
-
-
- ); -} - -export default AnnotationCreate; \ No newline at end of file diff --git a/app/tests/page.tsx b/app/tests/page.tsx index 964c3dc5..a0467350 100644 --- a/app/tests/page.tsx +++ b/app/tests/page.tsx @@ -1,9 +1,7 @@ "use client"; +import { ImageAnnotator } from "@/components/Projects/(classifications)/Annotating/Annotator"; import React, { useState } from "react"; -import StarnetLayout from "@/components/Layout/Starnet"; -import FreeformUploadData from "@/components/Projects/(classifications)/FreeForm"; -// import { TopographicMap } from "@/components/topographic-map"; export default function TestPage() { return ( @@ -11,7 +9,15 @@ export default function TestPage() { <> {/* */} {/* */} - +
+
+

Image Annotator

+

+ Upload an image and use the pen tool to draw annotations. When you're done, download the annotated image. +

+ +
+
// {/* */} ); diff --git a/components/Projects/(classifications)/Annotating/Annotator.tsx b/components/Projects/(classifications)/Annotating/Annotator.tsx new file mode 100644 index 00000000..4237f17f --- /dev/null +++ b/components/Projects/(classifications)/Annotating/Annotator.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Download, Upload } from 'lucide-react'; +import { ImageUploader } from './ImageUploader'; +import { DrawingCanvas } from './DrawingCanvas'; +import { DrawingControls } from './DrawingControls'; +import { downloadAnnotatedImage } from './DrawingUtils'; +import type { Point, Line } from '@/types/Annotation'; +import { useToast } from "@/hooks/toast"; + +export function ImageAnnotator() { + const [imageUrl, setImageUrl] = useState(null); + const [isDrawing, setIsDrawing] = useState(false); + const [currentLine, setCurrentLine] = useState({ points: [], color: '#ff0000', width: 2 }); + const [lines, setLines] = useState([]); + const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); + const [strokeColor, setStrokeColor] = useState('#ff0000'); + const [strokeWidth, setStrokeWidth] = useState(2); + const [isDownloading, setIsDownloading] = useState(false); + + const svgRef = useRef(null); + const imageRef = useRef(null); + const { toast } = useToast(); + + const handleImageUpload = (file: File) => { + const url = URL.createObjectURL(file); + setImageUrl(url); + setLines([]); + setCurrentLine({ points: [], color: strokeColor, width: strokeWidth }); + }; + + const handleImageLoad = (e: React.SyntheticEvent) => { + const img = e.currentTarget; + setDimensions({ + width: img.naturalWidth, + height: img.naturalHeight + }); + }; + + const handleMouseDown = (point: Point) => { + setIsDrawing(true); + setCurrentLine({ + points: [point], + color: strokeColor, + width: strokeWidth + }); + }; + + const handleMouseMove = (point: Point) => { + if (isDrawing) { + setCurrentLine(prev => ({ + ...prev, + points: [...prev.points, point] + })); + } + }; + + const handleMouseUp = () => { + if (isDrawing && currentLine.points.length > 0) { + setLines(prev => [...prev, currentLine]); + setCurrentLine({ points: [], color: strokeColor, width: strokeWidth }); + } + setIsDrawing(false); + }; + + const handleDownload = async () => { + if (!svgRef.current || !imageRef.current) { + toast({ + title: "Error", + description: "Image or drawing canvas not ready", + variant: "destructive", + }); + return; + } + + setIsDownloading(true); + try { + await downloadAnnotatedImage(svgRef.current, imageRef.current); + toast({ + title: "Success", + description: "Image downloaded successfully", + }); + } catch (error) { + toast({ + title: "Error", + description: error instanceof Error ? error.message : "Failed to download image", + variant: "destructive", + }); + } finally { + setIsDownloading(false); + } + }; + + return ( +
+
+ + {imageUrl && ( + + )} +
+ + {imageUrl && ( + <> + +
+ Upload an image to annotate + +
+ + )} + + {!imageUrl && ( +
+ +

Upload an image to begin annotating

+
+ )} +
+ ); +} \ No newline at end of file diff --git a/components/Projects/(classifications)/Annotating/DrawingCanvas.tsx b/components/Projects/(classifications)/Annotating/DrawingCanvas.tsx new file mode 100644 index 00000000..eb833dac --- /dev/null +++ b/components/Projects/(classifications)/Annotating/DrawingCanvas.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { forwardRef } from 'react'; +import type { Point, Line } from '@/types/Annotation'; +import { createLineGenerator, getMousePosition } from './DrawingUtils'; + +interface DrawingCanvasProps { + isDrawing: boolean; + currentLine: Line; + lines: Line[]; + onMouseDown: (point: Point) => void; + onMouseMove: (point: Point) => void; + onMouseUp: () => void; + width?: number; + height?: number; +} + +export const DrawingCanvas = forwardRef(({ + isDrawing, + currentLine, + lines, + onMouseDown, + onMouseMove, + onMouseUp, + width, + height, +}, ref) => { + const lineGenerator = createLineGenerator(); + + const handleMouseDown = (event: React.MouseEvent) => { + const svg = event.currentTarget; + const point = getMousePosition(event, svg); + onMouseDown(point); + }; + + const handleMouseMove = (event: React.MouseEvent) => { + if (!isDrawing) return; + const svg = event.currentTarget; + const point = getMousePosition(event, svg); + onMouseMove(point); + }; + + return ( + + {lines.map((line, i) => ( + + ))} + {currentLine.points.length > 0 && ( + + )} + + ); +}); + +DrawingCanvas.displayName = 'DrawingCanvas'; \ No newline at end of file diff --git a/components/Projects/(classifications)/Annotating/DrawingControls.tsx b/components/Projects/(classifications)/Annotating/DrawingControls.tsx new file mode 100644 index 00000000..c83e15c8 --- /dev/null +++ b/components/Projects/(classifications)/Annotating/DrawingControls.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import type { DrawingControls } from '@/types/Annotation'; + +export function DrawingControls({ + strokeColor, + strokeWidth, + onColorChange, + onWidthChange +}: DrawingControls) { + return ( +
+
+ + onColorChange(e.target.value)} + className="w-16 h-8 p-0" + /> +
+
+ + onWidthChange(Number(e.target.value))} + className="w-32" + /> + {strokeWidth}px +
+
+ ); +}; \ No newline at end of file diff --git a/components/Projects/(classifications)/Annotating/DrawingUtils.tsx b/components/Projects/(classifications)/Annotating/DrawingUtils.tsx new file mode 100644 index 00000000..9703cd70 --- /dev/null +++ b/components/Projects/(classifications)/Annotating/DrawingUtils.tsx @@ -0,0 +1,111 @@ +import type { Point } from '@/types/Annotation'; + +// Simple curve generator that creates a smooth path through points +export const createLineGenerator = () => { + return (points: Point[]): string => { + if (points.length < 2) return ''; + + // Move to the first point + let path = `M ${points[0].x},${points[0].y}`; + + // Create smooth curves between points + for (let i = 1; i < points.length - 2; i++) { + const current = points[i]; + const next = points[i + 1]; + + // Calculate control point as the midpoint between current and next + const controlX = (current.x + next.x) / 2; + const controlY = (current.y + next.y) / 2; + + path += ` Q ${current.x},${current.y} ${controlX},${controlY}`; + } + + // For the last two points + if (points.length >= 2) { + const last = points[points.length - 1]; + const secondLast = points[points.length - 2]; + path += ` L ${last.x},${last.y}`; + } + + return path; + }; +}; + +export const getMousePosition = ( + event: React.MouseEvent, + svg: SVGSVGElement +): Point => { + const CTM = svg.getScreenCTM(); + if (!CTM) return { x: 0, y: 0 }; + + const point = svg.createSVGPoint(); + point.x = event.clientX; + point.y = event.clientY; + + const transformedPoint = point.matrixTransform(CTM.inverse()); + return { + x: transformedPoint.x, + y: transformedPoint.y + }; +}; + +export const downloadAnnotatedImage = async ( + svgElement: SVGSVGElement, + imageElement: HTMLImageElement +): Promise => { + // Create canvas with the correct dimensions + const canvas = document.createElement('canvas'); + const width = imageElement.naturalWidth; + const height = imageElement.naturalHeight; + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Could not get canvas context'); + } + + // Draw the original image + ctx.drawImage(imageElement, 0, 0); + + // Convert SVG to data URL + const svgData = new XMLSerializer().serializeToString(svgElement); + const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' }); + const svgUrl = URL.createObjectURL(svgBlob); + + // Create and load SVG image + const svgImg = new Image(); + + try { + await new Promise((resolve, reject) => { + svgImg.onload = resolve; + svgImg.onerror = () => reject(new Error('Failed to load SVG image')); + svgImg.src = svgUrl; + }); + + // Draw SVG on top of the original image + ctx.drawImage(svgImg, 0, 0, width, height); + + // Convert to blob and download + const blob = await new Promise((resolve) => { + canvas.toBlob(resolve, 'image/png'); + }); + + if (!blob) { + throw new Error('Failed to create image blob'); + } + + // Create download link + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.download = 'annotated-image.png'; + link.href = url; + link.click(); + + // Cleanup + URL.revokeObjectURL(url); + URL.revokeObjectURL(svgUrl); + } catch (error) { + throw new Error(`Failed to process image: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +}; \ No newline at end of file diff --git a/components/Projects/(classifications)/Annotating/ImageUploader.tsx b/components/Projects/(classifications)/Annotating/ImageUploader.tsx new file mode 100644 index 00000000..7afb3ff1 --- /dev/null +++ b/components/Projects/(classifications)/Annotating/ImageUploader.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { Button } from '@/components/ui/button'; +import { Upload } from 'lucide-react'; + +interface ImageUploaderProps { + onImageUpload: (file: File) => void; +} + +export function ImageUploader({ onImageUpload }: ImageUploaderProps) { + const handleChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + onImageUpload(file); + } + }; + + return ( + <> + + + + ); +}; \ No newline at end of file diff --git a/components/Projects/(classifications)/Annotation.tsx b/components/Projects/(classifications)/Annotation.tsx deleted file mode 100644 index e0136744..00000000 --- a/components/Projects/(classifications)/Annotation.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useRef, useEffect, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; -import * as markerjs2 from "markerjs2"; - -interface ImageProps { - src: string; - onUpload: (dataUrl: string) => Promise; -} - -export default function ImageAnnotation({ src, onUpload }: ImageProps) { - const imageRef = useRef(null); - const [markerArea, setMarkerArea] = useState(null); - const [annotationState, setAnnotationState] = useState(null); - - useEffect(() => { - if (imageRef.current) { - const ma = new markerjs2.MarkerArea(imageRef.current); - setMarkerArea(ma); - - ma.addEventListener("render", (event) => { - if (imageRef.current) { - setAnnotationState(JSON.stringify(ma.getState())); - imageRef.current.src = event.dataUrl; - } - }); - - ma.addEventListener("close", () => { - setAnnotationState(JSON.stringify(ma.getState())); - }); - - ma.show(); // Automatically show the marker area when initialized - } - }, []); - - const showMarkerArea = () => { - if (markerArea) { - if (annotationState) { - markerArea.restoreState(JSON.parse(annotationState)); - } - markerArea.show(); - } - }; - - const downloadImage = async () => { - if (imageRef.current) { - const dataUrl = imageRef.current.src; // Get the annotated image data URL - await onUpload(dataUrl); - } - }; - - return ( - - - Annotate Image - Click the button to start annotating the image. - - -
- Annotated Image - - -
-
-
- ); -} \ No newline at end of file diff --git a/components/Projects/(classifications)/Annotation/AnnotationList.tsx b/components/Projects/(classifications)/Annotation/AnnotationList.tsx deleted file mode 100644 index fca011e7..00000000 --- a/components/Projects/(classifications)/Annotation/AnnotationList.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react'; - -interface AnnotationListProps { - annotations: Array<{ - type: string; - label: string; - }>; - onLabelChange: (index: number, newLabel: string) => void; -}; - -export default function AnnotationList({ annotations, onLabelChange }: AnnotationListProps) { - return ( -
-

Annotations

- {annotations.length === 0 ? ( -

No annotations yet

- ) : ( -
    - {annotations.map((annotation, index) => ( -
  • - - onLabelChange(index, e.target.value)} - className="flex-1 px-2 py-1 text-sm border rounded focus:outline-none focus:ring-2 focus:ring-blue-500" - /> -
  • - ))} -
- )} -
- ); -}; \ No newline at end of file diff --git a/components/Projects/(classifications)/Annotation/Canvas.tsx b/components/Projects/(classifications)/Annotation/Canvas.tsx deleted file mode 100644 index cb45daf3..00000000 --- a/components/Projects/(classifications)/Annotation/Canvas.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import React, { useState, useRef } from 'react'; -// import { Stage, Layer, Image, Rect, Text, Line } from 'react-konva'; -import useImage from 'use-image'; -import { Annotation } from '@/types/Annotation'; - - -interface CanvasProps { - imageUrl: string; - annotations: Annotation[]; - currentTool: string; - isDrawing: boolean; - setIsDrawing: (drawing: boolean) => void; - onAddAnnotation: (annotation: Annotation) => void; -} - -export default function Canvas() { - return ( - <> - ) -} - -// export default function Canvas({ -// imageUrl, -// annotations, -// currentTool, -// isDrawing, -// setIsDrawing, -// onAddAnnotation -// }: CanvasProps) { -// const [image] = useImage(imageUrl); -// const [points, setPoints] = useState([]); -// const [position, setPosition] = useState({ x: 0, y: 0 }); -// const stageRef = useRef(null); - -// const handleMouseDown = (e: any) => { -// if (!currentTool) return; - -// const pos = e.target.getStage().getPointerPosition(); -// setPosition({ x: pos.x, y: pos.y }); - -// if (currentTool === 'pen') { -// setIsDrawing(true); -// setPoints([pos.x, pos.y]); -// } -// }; - -// const handleMouseMove = (e: any) => { -// if (!isDrawing || currentTool !== 'pen') return; - -// const pos = e.target.getStage().getPointerPosition(); -// setPoints([...points, pos.x, pos.y]); -// }; - -// const handleMouseUp = (e: any) => { -// if (!currentTool) return; - -// const pos = e.target.getStage().getPointerPosition(); - -// if (currentTool === 'pen') { -// setIsDrawing(false); -// onAddAnnotation({ -// type: 'pen', -// points: points, -// label: 'Drawing' -// }); -// setPoints([]); -// } else if (currentTool === 'rectangle') { -// const width = pos.x - position.x; -// const height = pos.y - position.y; - -// onAddAnnotation({ -// type: 'rectangle', -// x: position.x, -// y: position.y, -// width, -// height, -// label: 'Rectangle' -// }); -// } -// }; - -// return ( -// -// -// {image && ( -// Uploaded image -// )} - -// {annotations.map((annotation, i) => { -// if (annotation.type === 'rectangle') { -// return ( -// -// -// -// -// ); -// } else if (annotation.type === 'pen') { -// return ( -// -// -// {annotation.points?.[0] !== undefined && annotation.points?.[1] !== undefined && ( -// -// )} -// -// ); -// } -// return null; -// })} - - -// {isDrawing && ( -// -// )} -// -// -// ); -// }; \ No newline at end of file diff --git a/components/Projects/(classifications)/Annotation/Toolbar.tsx b/components/Projects/(classifications)/Annotation/Toolbar.tsx deleted file mode 100644 index 4484d1f9..00000000 --- a/components/Projects/(classifications)/Annotation/Toolbar.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import { Square, Pencil, Download, Share2 } from 'lucide-react'; - -interface ToolbarProps { - currentTool: string; - onToolSelect: (tool: string) => void; - onDownload: () => void; - onShare: () => void; -}; - -export default function Toolbar({ currentTool, onToolSelect, onDownload, onShare }: ToolbarProps) { - return ( -
- - -
- - -
- ); -}; \ No newline at end of file diff --git a/components/Projects/Satellite/PlanetFour.tsx b/components/Projects/Satellite/PlanetFour.tsx index 7b44cd67..541acc58 100644 --- a/components/Projects/Satellite/PlanetFour.tsx +++ b/components/Projects/Satellite/PlanetFour.tsx @@ -6,7 +6,7 @@ import { useActivePlanet } from "@/context/ActivePlanet"; import ClassificationForm from "../(classifications)/PostForm"; import { Anomaly } from "../Zoodex/ClassifyOthersAnimals"; -import ImageAnnotation from "../(classifications)/Annotation"; +// import ImageAnnotation from "../(classifications)/Annotation"; import * as markerjs2 from "markerjs2"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; diff --git a/components/Projects/Telescopes/DailyMinorPlanet.tsx b/components/Projects/Telescopes/DailyMinorPlanet.tsx index 14375966..db4d0b78 100644 --- a/components/Projects/Telescopes/DailyMinorPlanet.tsx +++ b/components/Projects/Telescopes/DailyMinorPlanet.tsx @@ -6,7 +6,6 @@ import ClassificationForm from "../(classifications)/PostForm"; import { Anomaly } from "../Zoodex/ClassifyOthersAnimals"; import { Button } from "@/components/ui/button"; -import ImageAnnotation from "../(classifications)/Annotation"; interface Props { anomalyid: number | bigint; }; diff --git a/package.json b/package.json index ed04217e..121f897a 100644 --- a/package.json +++ b/package.json @@ -105,4 +105,4 @@ "tailwindcss": "^3.4.1", "typescript": "^5.6.3" } -} +} \ No newline at end of file diff --git a/types/Annotation.tsx b/types/Annotation.tsx index f796234a..9e674a58 100644 --- a/types/Annotation.tsx +++ b/types/Annotation.tsx @@ -1,9 +1,23 @@ -export interface Annotation { - type: 'rectangle' | 'pen'; - label: string; - x?: number; - y?: number; - width?: number; - height?: number; - points?: number[]; - } \ No newline at end of file +export interface Point { + x: number; + y: number; +}; + +export interface Line { + points: Point[]; + color: string; + width: number; +}; + +export interface DrawingState { + isDrawing: boolean; + currentLine: Line; + lines: Line[]; +}; + +export interface DrawingControls { + strokeColor: string; + strokeWidth: number; + onColorChange: (color: string) => void; + onWidthChange: (width: number) => void; +}; \ No newline at end of file