|
|
|
|
|
|
|
|
|
|
|
import React, { useState, useRef, useLayoutEffect } from 'react'; |
|
import { PaletteIcon, SparklesIcon, UploadCloudIcon, AlertTriangleIcon } from '../components/icons'; |
|
import Spinner from '../components/Spinner'; |
|
import { generateStylingBlueprint, 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 DesignStudioView: 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 [prompt, setPrompt] = useState<string>(''); |
|
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(() => { |
|
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 |
|
}); |
|
} |
|
}, [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; |
|
} |
|
if (!prompt.trim()) { |
|
setError("Please enter a styling instruction."); |
|
return; |
|
} |
|
|
|
setStatus(AppStatus.ANALYZING); |
|
setError(''); |
|
setBlueprint(null); |
|
|
|
try { |
|
const result = await generateStylingBlueprint(image.base64, prompt); |
|
if (result) { |
|
setBlueprint(result); |
|
setStatus(AppStatus.SUCCESS); |
|
} else { |
|
throw new Error('Failed to generate styling blueprint. The AI may be busy or the prompt was not specific enough. Please try again.'); |
|
} |
|
} catch (e: any) { |
|
setError(e.message); |
|
setStatus(AppStatus.ERROR); |
|
} |
|
}; |
|
|
|
const presetPrompts = [ |
|
"Style as a classic formal upright (Chokkan) with clear, triangular lines.", |
|
"Restyle into a dramatic cascade (Kengai) form, as if hanging off a cliff.", |
|
"Imagine this as a windswept (Fukinagashi) tree, with all branches flowing in one direction.", |
|
"Show a semi-cascade (Han-kengai) version.", |
|
"Give it a more mature appearance with a denser, more refined canopy of foliage pads.", |
|
"Incorporate some dramatic deadwood (Jin and Shari) on the trunk and branches.", |
|
]; |
|
|
|
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"> |
|
<PaletteIcon className="w-8 h-8 text-purple-600" /> |
|
AI Design Studio |
|
</h2> |
|
<p className="mt-4 text-lg leading-8 text-stone-600 max-w-3xl mx-auto"> |
|
Describe your desired outcome, and Yuki will generate a visual guide on your photo. Using SVG graphics and text labels, she shows you exactly where to prune, wire, or shape your tree to achieve your vision. |
|
</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</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-purple-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> |
|
|
|
<div> |
|
<label htmlFor="style-prompt" className="block text-sm font-medium text-stone-900">2. Describe Your Goal</label> |
|
<textarea |
|
id="style-prompt" |
|
rows={3} |
|
value={prompt} |
|
onChange={e => setPrompt(e.target.value)} |
|
className="mt-1 block w-full rounded-md border-stone-300 shadow-sm focus:border-purple-500 focus:ring-purple-500" |
|
placeholder="e.g., 'Make it look like a windswept tree'" |
|
/> |
|
</div> |
|
<div className="space-y-2"> |
|
<p className="text-sm font-medium text-stone-700">Or try a preset goal:</p> |
|
<div className="flex flex-wrap gap-2"> |
|
{presetPrompts.map((p, i) => ( |
|
<button key={i} onClick={() => setPrompt(p)} className="text-xs bg-stone-100 text-stone-700 px-3 py-1 rounded-full hover:bg-purple-100 hover:text-purple-800 transition-colors"> |
|
{p.split('(')[0].trim()} |
|
</button> |
|
))} |
|
</div> |
|
</div> |
|
<button onClick={handleGenerate} disabled={status === AppStatus.ANALYZING || !image || !aiConfigured} className="w-full flex items-center justify-center gap-2 rounded-md bg-purple-600 px-4 py-3 text-sm font-semibold text-white shadow-sm hover:bg-purple-500 disabled:bg-stone-400 disabled:cursor-not-allowed"> |
|
<SparklesIcon className="w-5 h-5"/> {status === AppStatus.ANALYZING ? 'Generating Blueprint...' : '3. Generate Blueprint'} |
|
</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"> |
|
{image ? ( |
|
<> |
|
<img src={image.preview} alt="Bonsai" className="w-full h-full object-contain rounded-lg" /> |
|
{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 blueprint 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 sketching your vision..." /></div>} |
|
</div> |
|
{blueprint && status === AppStatus.SUCCESS && ( |
|
<div className="mt-4 p-4 bg-purple-50 border-l-4 border-purple-500 rounded-r-lg"> |
|
<h4 className="font-bold text-purple-800">Yuki's Summary</h4> |
|
<p className="text-sm text-purple-700 mt-1">{blueprint.summary}</p> |
|
</div> |
|
)} |
|
</div> |
|
</div> |
|
</div> |
|
); |
|
}; |
|
|
|
export default DesignStudioView; |