import React, { useState, useRef, useLayoutEffect } from 'react'; import { RootsIcon, SparklesIcon, UploadCloudIcon, AlertTriangleIcon } from '../components/icons'; import Spinner from '../components/Spinner'; import { generateNebariBlueprint, isAIConfigured } from '../services/geminiService'; import { AppStatus, AnnotationType } from '../types'; import type { StylingBlueprint, StylingAnnotation, SvgPoint, View } from '../types'; const ANNOTATION_STYLES: { [key in AnnotationType]: React.CSSProperties } = { [AnnotationType.PruneLine]: { stroke: '#ef4444', strokeWidth: 3, strokeDasharray: '6 4', fill: 'none' }, [AnnotationType.RemoveBranch]: { stroke: '#ef4444', strokeWidth: 2, fill: 'rgba(239, 68, 68, 0.3)' }, [AnnotationType.WireDirection]: { stroke: '#3b82f6', strokeWidth: 3, fill: 'none', markerEnd: 'url(#arrow-head-blue)' }, [AnnotationType.FoliageRefinement]: { stroke: '#22c55e', strokeWidth: 3, strokeDasharray: '8 5', fill: 'rgba(34, 197, 94, 0.2)' }, [AnnotationType.JinShari]: { stroke: '#a16207', strokeWidth: 2, fill: 'rgba(161, 98, 7, 0.25)', strokeDasharray: '3 3' }, [AnnotationType.TrunkLine]: { stroke: '#f97316', strokeWidth: 4, fill: 'none', opacity: 0.8, markerEnd: 'url(#arrow-head-orange)' }, [AnnotationType.ExposeRoot]: { stroke: '#9333ea', strokeWidth: 2, fill: 'rgba(147, 51, 234, 0.2)', strokeDasharray: '5 5' }, }; const SvgAnnotation: React.FC<{ annotation: StylingAnnotation, scale: { x: number, y: number } }> = ({ annotation, scale }) => { const style = ANNOTATION_STYLES[annotation.type]; const { type, points, path, label } = annotation; const transformPoints = (pts: SvgPoint[]): string => { return pts.map(p => `${p.x * scale.x},${p.y * scale.y}`).join(' '); }; const scalePath = (pathData: string): string => { return pathData.replace(/([0-9.]+)/g, (match, number, offset) => { const precedingChar = pathData[offset - 1]; const isY = precedingChar === ',' || (precedingChar === ' ' && pathData.substring(0, offset).split(' ').length % 2 === 0); return isY ? (parseFloat(number) * scale.y).toFixed(2) : (parseFloat(number) * scale.x).toFixed(2); }); }; const positionLabel = (): SvgPoint => { if (points && points.length > 0) { const total = points.reduce((acc, p) => ({ x: acc.x + p.x, y: acc.y + p.y }), { x: 0, y: 0 }); return { x: (total.x / points.length) * scale.x, y: (total.y / points.length) * scale.y }; } if (path) { const commands = path.split(' '); const lastX = parseFloat(commands[commands.length - 2]); const lastY = parseFloat(commands[commands.length - 1]); return { x: lastX * scale.x, y: lastY * scale.y }; } return { x: 0, y: 0 }; }; const labelPos = positionLabel(); const renderShape = () => { switch (type) { case AnnotationType.PruneLine: return ; case AnnotationType.FoliageRefinement: case AnnotationType.RemoveBranch: case AnnotationType.JinShari: case AnnotationType.ExposeRoot: return ; case AnnotationType.WireDirection: case AnnotationType.TrunkLine: return ; default: return null; } }; return ( {renderShape()} {label} ); }; const NebariDeveloperView: React.FC<{ setActiveView: (view: View) => void }> = ({ setActiveView }) => { const [status, setStatus] = useState(AppStatus.IDLE); const [image, setImage] = useState<{ preview: string; base64: string } | null>(null); const [blueprint, setBlueprint] = useState(null); const [error, setError] = useState(''); const [viewBox, setViewBox] = useState({ width: 0, height: 0 }); const [scale, setScale] = useState({ x: 1, y: 1 }); const fileInputRef = useRef(null); const imageContainerRef = useRef(null); const aiConfigured = isAIConfigured(); useLayoutEffect(() => { const handleResize = () => { if (image && blueprint && imageContainerRef.current) { const { clientWidth, clientHeight } = imageContainerRef.current; setViewBox({ width: clientWidth, height: clientHeight }); setScale({ x: clientWidth / blueprint.canvas.width, y: clientHeight / blueprint.canvas.height }); } }; handleResize(); window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, [image, blueprint]); const handleFileChange = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (file) { if (file.size > 4 * 1024 * 1024) { setError("File size exceeds 4MB. Please upload a smaller image."); return; } const reader = new FileReader(); reader.onloadend = () => { const base64String = (reader.result as string).split(',')[1]; setImage({ preview: reader.result as string, base64: base64String }); setError(''); setBlueprint(null); setStatus(AppStatus.IDLE); }; reader.onerror = () => setError("Failed to read the file."); reader.readAsDataURL(file); } }; const handleGenerate = async () => { if (!image) { setError("Please upload an image first."); return; } setStatus(AppStatus.ANALYZING); setError(''); setBlueprint(null); try { const result = await generateNebariBlueprint(image.base64); if (result) { setBlueprint(result); setStatus(AppStatus.SUCCESS); } else { throw new Error('Failed to generate nebari guide. The AI may be busy or the image unclear. Please try again.'); } } catch (e: any) { setError(e.message); setStatus(AppStatus.ERROR); } }; return (

Nebari Developer

Get an AI-generated plan to develop the perfect surface roots (Nebari) for your bonsai.

{/* Left Column: Controls */}
fileInputRef.current?.click()} className="mt-1 flex justify-center p-4 rounded-lg border-2 border-dashed border-stone-300 hover:border-orange-600 transition-colors cursor-pointer">
{image ?

Image Loaded!

: }

{image ? 'Click to change image' : 'Click to upload'}

{error &&

{error}

} {!aiConfigured && (

Please set your Gemini API key in the{' '} {' '}to enable this feature.

)}
{/* Right Column: Canvas */}
{image ? ( <> Bonsai Nebari {blueprint && ( {blueprint.annotations.map((anno, i) => ( ))} )} ) : (

Your image and root plan will appear here

)} {status === AppStatus.ANALYZING &&
}
{blueprint && status === AppStatus.SUCCESS && (

Yuki's Nebari Strategy

{blueprint.summary}

)}
); }; export default NebariDeveloperView;