Yuki / views /NebariDeveloperView.tsx
Severian's picture
Upload 43 files
be02369 verified
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;