|
|
|
|
|
|
|
|
|
|
| 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; |