| 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 <polyline points={transformPoints(points || [])} style={style} />; | |
| case AnnotationType.FoliageRefinement: | |
| case AnnotationType.RemoveBranch: | |
| case AnnotationType.JinShari: | |
| case AnnotationType.ExposeRoot: | |
| return <polygon points={transformPoints(points || [])} style={style} />; | |
| case AnnotationType.WireDirection: | |
| case AnnotationType.TrunkLine: | |
| return <path d={scalePath(path || '')} style={style} />; | |
| default: | |
| return null; | |
| } | |
| }; | |
| return ( | |
| <g className="annotation-group transition-opacity hover:opacity-100 opacity-80"> | |
| {renderShape()} | |
| <text x={labelPos.x + 5} y={labelPos.y - 5} fill="white" stroke="black" strokeWidth="0.5px" paintOrder="stroke" fontSize="14" fontWeight="bold" className="pointer-events-none"> | |
| {label} | |
| </text> | |
| </g> | |
| ); | |
| }; | |
| const NebariDeveloperView: React.FC<{ setActiveView: (view: View) => void }> = ({ setActiveView }) => { | |
| const [status, setStatus] = useState<AppStatus>(AppStatus.IDLE); | |
| const [image, setImage] = useState<{ preview: string; base64: string } | null>(null); | |
| const [blueprint, setBlueprint] = useState<StylingBlueprint | null>(null); | |
| const [error, setError] = useState<string>(''); | |
| const [viewBox, setViewBox] = useState({ width: 0, height: 0 }); | |
| const [scale, setScale] = useState({ x: 1, y: 1 }); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| const imageContainerRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => { | |
| 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 ( | |
| <div className="space-y-8 max-w-7xl mx-auto"> | |
| <header className="text-center"> | |
| <h2 className="text-3xl font-bold tracking-tight text-stone-900 sm:text-4xl flex items-center justify-center gap-3"> | |
| <RootsIcon className="w-8 h-8 text-orange-800" /> | |
| Nebari Developer | |
| </h2> | |
| <p className="mt-4 text-lg leading-8 text-stone-600"> | |
| Get an AI-generated plan to develop the perfect surface roots (Nebari) for your bonsai. | |
| </p> | |
| </header> | |
| <div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start"> | |
| {/* Left Column: Controls */} | |
| <div className="bg-white p-6 rounded-xl shadow-lg border border-stone-200 space-y-4 lg:col-span-1"> | |
| <div> | |
| <label className="block text-sm font-medium text-stone-900">1. Upload Photo of Tree Base</label> | |
| <div onClick={() => 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"> | |
| <div className="text-center"> | |
| {image ? <p className="text-green-700 font-semibold">Image Loaded!</p> : <UploadCloudIcon className="mx-auto h-10 w-10 text-stone-400" />} | |
| <p className="mt-1 text-sm text-stone-600">{image ? 'Click to change image' : 'Click to upload'}</p> | |
| </div> | |
| <input ref={fileInputRef} type="file" className="sr-only" onChange={handleFileChange} accept="image/png, image/jpeg" /> | |
| </div> | |
| </div> | |
| <button onClick={handleGenerate} disabled={status === AppStatus.ANALYZING || !image || !aiConfigured} className="w-full flex items-center justify-center gap-2 rounded-md bg-orange-800 px-4 py-3 text-sm font-semibold text-white shadow-sm hover:bg-orange-700 disabled:bg-stone-400 disabled:cursor-not-allowed"> | |
| <SparklesIcon className="w-5 h-5"/> {status === AppStatus.ANALYZING ? 'Developing Plan...' : '2. Generate Nebari Plan'} | |
| </button> | |
| {error && <p className="text-sm text-red-600 mt-2 bg-red-50 p-3 rounded-md">{error}</p>} | |
| {!aiConfigured && ( | |
| <div className="mt-4 p-3 bg-yellow-50 text-yellow-800 rounded-lg border border-yellow-200 text-center"> | |
| <p className="text-sm"> | |
| Please set your Gemini API key in the{' '} | |
| <button onClick={() => setActiveView('settings')} className="font-bold underline hover:text-yellow-900"> | |
| Settings page | |
| </button> | |
| {' '}to enable this feature. | |
| </p> | |
| </div> | |
| )} | |
| </div> | |
| {/* Right Column: Canvas */} | |
| <div className="bg-white p-4 rounded-xl shadow-lg border border-stone-200 lg:col-span-2"> | |
| <div ref={imageContainerRef} className="relative w-full aspect-w-4 aspect-h-3 bg-stone-100 rounded-lg overflow-hidden"> | |
| {image ? ( | |
| <> | |
| <img src={image.preview} alt="Bonsai Nebari" className="w-full h-full object-contain" /> | |
| {blueprint && ( | |
| <svg | |
| className="absolute top-0 left-0 w-full h-full" | |
| viewBox={`0 0 ${viewBox.width} ${viewBox.height}`} | |
| xmlns="http://www.w3.org/2000/svg" | |
| > | |
| <defs> | |
| <marker id="arrow-head-blue" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"> | |
| <path d="M 0 0 L 10 5 L 0 10 z" fill="#3b82f6" /> | |
| </marker> | |
| <marker id="arrow-head-orange" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"> | |
| <path d="M 0 0 L 10 5 L 0 10 z" fill="#f97316" /> | |
| </marker> | |
| </defs> | |
| {blueprint.annotations.map((anno, i) => ( | |
| <SvgAnnotation key={i} annotation={anno} scale={scale} /> | |
| ))} | |
| </svg> | |
| )} | |
| </> | |
| ) : ( | |
| <div className="flex items-center justify-center h-full"> | |
| <p className="text-stone-500">Your image and root plan will appear here</p> | |
| </div> | |
| )} | |
| {status === AppStatus.ANALYZING && <div className="absolute inset-0 flex items-center justify-center bg-white/75"><Spinner text="Yuki is inspecting the roots..." /></div>} | |
| </div> | |
| {blueprint && status === AppStatus.SUCCESS && ( | |
| <div className="mt-4 p-4 bg-orange-50 border-l-4 border-orange-500 rounded-r-lg"> | |
| <h4 className="font-bold text-orange-800">Yuki's Nebari Strategy</h4> | |
| <p className="text-sm text-orange-700 mt-1">{blueprint.summary}</p> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default NebariDeveloperView; |