Refactor PlantSimulator to enhance plant growth modeling and UI
- Introduced new Branch and Fruit interfaces to better represent plant structure. - Updated PlantState to include branches and fruits, improving growth simulation. - Enhanced SVG rendering with new gradients and animations for a more dynamic visual experience. - Adjusted constants for maximum height, yield, and dimensions to improve realism. - Added helper functions for quadratic bezier calculations to refine plant shape rendering.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
import { useEffect, useRef, useState, useCallback, memo } from 'react'
|
||||||
import {
|
import {
|
||||||
Chart as ChartJS,
|
Chart as ChartJS,
|
||||||
LineElement,
|
LineElement,
|
||||||
@@ -21,35 +21,62 @@ ChartJS.register(LineElement, PointElement, LinearScale, CategoryScale, Title, T
|
|||||||
interface Leaf {
|
interface Leaf {
|
||||||
id: number
|
id: number
|
||||||
side: 'left' | 'right'
|
side: 'left' | 'right'
|
||||||
heightFraction: number // 0..1 relative to stem
|
heightFraction: number
|
||||||
angle: number // base rotation angle in degrees
|
scale: number
|
||||||
scale: number // 0..1 grow-in scale
|
swayOffset: number
|
||||||
swayOffset: number // random sway phase offset
|
length: number
|
||||||
|
branchId: number | null // null = on main stem, number = on a branch
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Branch {
|
||||||
|
id: number
|
||||||
|
side: 'left' | 'right'
|
||||||
|
heightFraction: number // where on stem it starts (0..1)
|
||||||
|
length: number // branch length px
|
||||||
|
angle: number // base angle in degrees
|
||||||
|
scale: number // 0..1 grow-in
|
||||||
|
swayOffset: number
|
||||||
|
thickness: number // px
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Fruit {
|
||||||
|
id: number
|
||||||
|
branchId: number | null
|
||||||
|
leafId: number
|
||||||
|
side: 'left' | 'right'
|
||||||
|
scale: number
|
||||||
|
swayOffset: number
|
||||||
|
color: string
|
||||||
|
size: number // varied fruit radius
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PlantState {
|
interface PlantState {
|
||||||
height: number // current stem height px (0 → MAX_HEIGHT)
|
height: number
|
||||||
leaves: Leaf[]
|
leaves: Leaf[]
|
||||||
|
branches: Branch[]
|
||||||
|
fruits: Fruit[]
|
||||||
tick: number
|
tick: number
|
||||||
yield: number // accumulated yield in grams (0 → MAX_YIELD)
|
yield: number
|
||||||
yieldRate: number // current yield production rate g/s
|
yieldRate: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EnvironmentSettings {
|
interface EnvironmentSettings {
|
||||||
light: number // 0..100
|
light: number
|
||||||
water: number // 0..100
|
water: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const MAX_HEIGHT = 260
|
const MAX_HEIGHT = 280
|
||||||
const MAX_YIELD = 500 // max yield in grams
|
const MAX_YIELD = 500
|
||||||
const SVG_WIDTH = 200
|
const SVG_W = 280
|
||||||
const SVG_HEIGHT = 320
|
const SVG_H = 400
|
||||||
const STEM_X = SVG_WIDTH / 2
|
const STEM_X = SVG_W / 2
|
||||||
const BASE_Y = SVG_HEIGHT - 10
|
const BASE_Y = SVG_H - 24
|
||||||
const LEAF_INTERVAL_PX = 36 // stem height growth between new leaves
|
const LEAF_INTERVAL_PX = 30
|
||||||
const MAX_LEAVES = 12
|
const MAX_LEAVES = 14
|
||||||
|
const MAX_BRANCHES = 6
|
||||||
|
const FRUIT_COLORS = ['#ef4444', '#f97316', '#eab308', '#f472b6', '#a855f7', '#22c55e']
|
||||||
|
|
||||||
// ─── Helper: compute growth speed multiplier from env settings ────────────────
|
// ─── Helper: compute growth speed multiplier from env settings ────────────────
|
||||||
|
|
||||||
@@ -69,74 +96,357 @@ function computeYieldRate(env: EnvironmentSettings, leafCount: number, heightPro
|
|||||||
return parseFloat((MAX_YIELD * 0.012 * lightFactor * waterFactor * leafFactor * maturityFactor).toFixed(3))
|
return parseFloat((MAX_YIELD * 0.012 * lightFactor * waterFactor * leafFactor * maturityFactor).toFixed(3))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Helpers: quadratic bezier point / tangent at t ─────────────────────────
|
||||||
|
|
||||||
|
function qBez(p0: number, p1: number, p2: number, t: number) {
|
||||||
|
return (1 - t) * (1 - t) * p0 + 2 * (1 - t) * t * p1 + t * t * p2
|
||||||
|
}
|
||||||
|
|
||||||
|
function qBezTan(p0: number, p1: number, p2: number, t: number) {
|
||||||
|
return 2 * (1 - t) * (p1 - p0) + 2 * t * (p2 - p1)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Plant SVG Component ──────────────────────────────────────────────────────
|
// ─── Plant SVG Component ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function PlantSVG({ plant, tick }: { plant: PlantState; tick: number }) {
|
function PlantSVG({ plant, tick, running }: { plant: PlantState; tick: number; running: boolean }) {
|
||||||
const stemTop = BASE_Y - plant.height
|
const stemTop = BASE_Y - plant.height
|
||||||
const stemHeight = plant.height
|
const progress = plant.height / MAX_HEIGHT
|
||||||
|
const stemW = 6 + Math.min(progress * 7, 7)
|
||||||
|
|
||||||
|
const sway = Math.sin(tick / 55) * 3
|
||||||
|
|
||||||
|
// Stem control points — quadratic Bezier
|
||||||
|
const s0x = STEM_X, s0y = BASE_Y
|
||||||
|
const s1x = STEM_X + sway * 0.4, s1y = stemTop + plant.height * 0.5
|
||||||
|
const s2x = STEM_X + sway, s2y = stemTop
|
||||||
|
|
||||||
|
// Get point on main stem at fraction t (0=base, 1=top)
|
||||||
|
const stemPt = (t: number) => ({
|
||||||
|
x: qBez(s0x, s1x, s2x, t),
|
||||||
|
y: qBez(s0y, s1y, s2y, t)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get angle on main stem at t
|
||||||
|
const stemAngle = (t: number) => {
|
||||||
|
const dx = qBezTan(s0x, s1x, s2x, t)
|
||||||
|
const dy = qBezTan(s0y, s1y, s2y, t)
|
||||||
|
return (Math.atan2(dy, dx) * 180) / Math.PI
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get branch tip position
|
||||||
|
const branchTip = (b: Branch) => {
|
||||||
|
const base = stemPt(b.heightFraction)
|
||||||
|
const dir = b.side === 'left' ? -1 : 1
|
||||||
|
const bSway = Math.sin(tick / 30 + b.swayOffset) * 4 * b.scale
|
||||||
|
const rad = ((b.angle + bSway) * Math.PI) / 180
|
||||||
|
const len = b.length * b.scale
|
||||||
|
return {
|
||||||
|
bx: base.x,
|
||||||
|
by: base.y,
|
||||||
|
tx: base.x + Math.cos(rad) * len * dir,
|
||||||
|
ty: base.y + Math.sin(rad) * len,
|
||||||
|
cx: base.x + Math.cos(rad) * len * 0.5 * dir,
|
||||||
|
cy: base.y + Math.sin(rad) * len * 0.35 - 8 * b.scale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg width={SVG_W} height={SVG_H} viewBox={`0 0 ${SVG_W} ${SVG_H}`} className='drop-shadow-2xl'>
|
||||||
width={SVG_WIDTH}
|
<defs>
|
||||||
height={SVG_HEIGHT}
|
<linearGradient id='sg' x1='0' y1='1' x2='0' y2='0'>
|
||||||
viewBox={`0 0 ${SVG_WIDTH} ${SVG_HEIGHT}`}
|
<stop offset='0%' stopColor='#24462e' />
|
||||||
className='drop-shadow-lg'
|
<stop offset='50%' stopColor='#3d7a4f' />
|
||||||
>
|
<stop offset='100%' stopColor='#6ab870' />
|
||||||
{/* Soil */}
|
</linearGradient>
|
||||||
<ellipse cx={STEM_X} cy={BASE_Y + 4} rx={40} ry={8} fill='#8B6914' opacity={0.6} />
|
<linearGradient id='bg' x1='0' y1='0' x2='0' y2='1'>
|
||||||
|
<stop offset='0%' stopColor='#4a8c5a' />
|
||||||
|
<stop offset='100%' stopColor='#2e5a38' />
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id='soilG' cx='50%' cy='40%' r='60%'>
|
||||||
|
<stop offset='0%' stopColor='#b08030' />
|
||||||
|
<stop offset='100%' stopColor='#4a2c0a' />
|
||||||
|
</radialGradient>
|
||||||
|
<filter id='fglow' x='-50%' y='-50%' width='200%' height='200%'>
|
||||||
|
<feGaussianBlur stdDeviation='2' result='b' />
|
||||||
|
<feMerge><feMergeNode in='b' /><feMergeNode in='SourceGraphic' /></feMerge>
|
||||||
|
</filter>
|
||||||
|
<filter id='lsh' x='-30%' y='-30%' width='160%' height='160%'>
|
||||||
|
<feDropShadow dx='0.8' dy='1.2' stdDeviation='1.2' floodColor='#0a2010' floodOpacity='0.35' />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
{/* Stem */}
|
{/* ── Floating pollen particles ── */}
|
||||||
{stemHeight > 0 && (
|
{running && progress > 0.35 && [0, 1, 2, 3, 4, 5].map(i => {
|
||||||
<rect
|
const px = STEM_X + Math.sin(tick / 28 + i * 1.1) * 40
|
||||||
x={STEM_X - 4}
|
const cycle = (tick * 0.35 + i * 30) % 80
|
||||||
y={stemTop}
|
const py = stemTop - 12 - cycle
|
||||||
width={8}
|
return <circle key={`p${i}`} cx={px} cy={py} r={1.4} fill='#fde68a' opacity={Math.max(0, 0.55 - cycle / 160)} />
|
||||||
height={stemHeight}
|
})}
|
||||||
rx={4}
|
|
||||||
fill='#4a7c59'
|
{/* ── Soil mound ── */}
|
||||||
className='transition-all duration-300'
|
<ellipse cx={STEM_X} cy={BASE_Y + 8} rx={58} ry={14} fill='url(#soilG)' opacity={0.9} />
|
||||||
/>
|
{[-24, -10, 4, 16, 28].map(dx => (
|
||||||
|
<line key={dx} x1={STEM_X + dx} y1={BASE_Y + 5} x2={STEM_X + dx + 5} y2={BASE_Y + 10}
|
||||||
|
stroke='#2a1400' strokeWidth={0.8} opacity={0.35} />
|
||||||
|
))}
|
||||||
|
{/* Small grass tufts */}
|
||||||
|
{[-30, -14, 18, 32].map((dx, i) => (
|
||||||
|
<path key={`grass${i}`}
|
||||||
|
d={`M${STEM_X + dx} ${BASE_Y + 3} q${2} ${-6} ${0} ${-10} M${STEM_X + dx + 3} ${BASE_Y + 3} q${-1} ${-5} ${1} ${-8}`}
|
||||||
|
stroke='#5a9a5a' strokeWidth={0.8} fill='none' opacity={0.4} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* ── Roots ── */}
|
||||||
|
{plant.height > 15 && (
|
||||||
|
<g opacity={0.4 + progress * 0.15}>
|
||||||
|
{[
|
||||||
|
{ dx: -22, dy: 22, w: 2.2, cx: -10, cy: 8 },
|
||||||
|
{ dx: 18, dy: 20, w: 1.8, cx: 8, cy: 10 },
|
||||||
|
{ dx: -8, dy: 24, w: 1.2, cx: -4, cy: 12 },
|
||||||
|
{ dx: 12, dy: 18, w: 1, cx: 6, cy: 14 }
|
||||||
|
].map((r, i) => (
|
||||||
|
<path key={`root${i}`}
|
||||||
|
d={`M${STEM_X} ${BASE_Y} Q${STEM_X + r.cx} ${BASE_Y + r.cy} ${STEM_X + r.dx} ${BASE_Y + r.dy}`}
|
||||||
|
stroke='#3b2a12' strokeWidth={r.w} fill='none' strokeLinecap='round' />
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Leaves */}
|
{/* ── Main stem (quadratic bezier) ── */}
|
||||||
{plant.leaves.map(leaf => {
|
{plant.height > 0 && (
|
||||||
const leafY = BASE_Y - leaf.heightFraction * plant.height
|
<>
|
||||||
const swayAngle = Math.sin((tick / 18 + leaf.swayOffset) * 1) * 4
|
<path d={`M${s0x} ${s0y} Q${s1x} ${s1y} ${s2x} ${s2y}`}
|
||||||
const baseAngle = leaf.side === 'left' ? -30 - swayAngle : 30 + swayAngle
|
stroke='url(#sg)' strokeWidth={stemW} fill='none' strokeLinecap='round' />
|
||||||
|
{/* Stem highlight vein */}
|
||||||
|
<path d={`M${s0x + 1.5} ${s0y - 6} Q${s1x + 1.5} ${s1y} ${s2x + 1.5} ${s2y + 6}`}
|
||||||
|
stroke='rgba(160,220,140,0.2)' strokeWidth={1.8} fill='none' strokeLinecap='round' />
|
||||||
|
{/* Stem node bumps — small circles at branch attachment points */}
|
||||||
|
{plant.branches.map(b => {
|
||||||
|
const pt = stemPt(b.heightFraction)
|
||||||
|
return <circle key={`node${b.id}`} cx={pt.x} cy={pt.y} r={stemW * 0.5 + 1}
|
||||||
|
fill='#3a6a42' opacity={b.scale * 0.6} />
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Branches (curved) ── */}
|
||||||
|
{plant.branches.map(b => {
|
||||||
|
const { bx, by, tx, ty, cx, cy } = branchTip(b)
|
||||||
|
const bw = b.thickness * b.scale
|
||||||
return (
|
return (
|
||||||
<g
|
<g key={`br${b.id}`} opacity={b.scale}>
|
||||||
key={leaf.id}
|
<path d={`M${bx} ${by} Q${cx} ${cy} ${tx} ${ty}`}
|
||||||
transform={`translate(${STEM_X}, ${leafY})`}
|
stroke='url(#bg)' strokeWidth={bw} fill='none' strokeLinecap='round' />
|
||||||
style={{ transformOrigin: `${STEM_X}px ${leafY}px` }}
|
{/* branch highlight */}
|
||||||
>
|
<path d={`M${bx} ${by} Q${cx - 0.5} ${cy - 0.5} ${tx} ${ty}`}
|
||||||
<ellipse
|
stroke='rgba(150,210,130,0.15)' strokeWidth={bw * 0.4} fill='none' strokeLinecap='round' />
|
||||||
cx={leaf.side === 'left' ? -22 * leaf.scale : 22 * leaf.scale}
|
{/* small thorn/bud at tip */}
|
||||||
cy={-6 * leaf.scale}
|
<circle cx={tx} cy={ty} r={bw * 0.5 + 0.5} fill='#5a9a52' opacity={0.7} />
|
||||||
rx={22 * leaf.scale}
|
|
||||||
ry={10 * leaf.scale}
|
|
||||||
fill={`hsl(${115 + leaf.id * 3}, 60%, ${38 + leaf.id * 2}%)`}
|
|
||||||
transform={`rotate(${baseAngle})`}
|
|
||||||
opacity={leaf.scale}
|
|
||||||
/>
|
|
||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Bud / flower at the top */}
|
{/* ── Leaves ── */}
|
||||||
{stemHeight > MAX_HEIGHT * 0.85 && (
|
{plant.leaves.map(leaf => {
|
||||||
<g>
|
let ax: number, ay: number
|
||||||
<circle cx={STEM_X} cy={stemTop - 8} r={8} fill='#f9c74f' opacity={0.9} />
|
if (leaf.branchId != null) {
|
||||||
<circle cx={STEM_X} cy={stemTop - 8} r={4} fill='#f3722c' />
|
const branch = plant.branches.find(b => b.id === leaf.branchId)
|
||||||
</g>
|
if (!branch) return null
|
||||||
)}
|
const tip = branchTip(branch)
|
||||||
|
ax = tip.tx
|
||||||
|
ay = tip.ty
|
||||||
|
} else {
|
||||||
|
const pt = stemPt(leaf.heightFraction)
|
||||||
|
ax = pt.x
|
||||||
|
ay = pt.y
|
||||||
|
}
|
||||||
|
|
||||||
|
const swayA = Math.sin(tick / 20 + leaf.swayOffset) * 6
|
||||||
|
const dir = leaf.side === 'left' ? -1 : 1
|
||||||
|
const rot = dir * 35 + swayA
|
||||||
|
const lx = leaf.length * leaf.scale
|
||||||
|
const ly = leaf.length * 0.4 * leaf.scale
|
||||||
|
const hue = 110 + leaf.id * 3
|
||||||
|
const lit = 30 + leaf.id * 1.8
|
||||||
|
const c1 = `hsl(${hue},65%,${lit}%)`
|
||||||
|
const c2 = `hsl(${hue},55%,${lit + 14}%)`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={`lf${leaf.id}`} transform={`translate(${ax},${ay}) rotate(${rot})`}
|
||||||
|
filter='url(#lsh)' opacity={Math.min(leaf.scale * 1.4, 1)}>
|
||||||
|
{/* Leaf shape — custom path for more natural look */}
|
||||||
|
<path
|
||||||
|
d={`M0,0 Q${dir * lx * 0.3},${-ly * 0.9} ${dir * lx},${-ly * 0.15}
|
||||||
|
Q${dir * lx * 0.55},${ly * 0.5} 0,0`}
|
||||||
|
fill={c1} />
|
||||||
|
{/* Midrib */}
|
||||||
|
<line x1={0} y1={0} x2={dir * lx * 0.95} y2={-ly * 0.12}
|
||||||
|
stroke={c2} strokeWidth={0.9} opacity={0.7} />
|
||||||
|
{/* Side veins */}
|
||||||
|
{[0.25, 0.45, 0.65, 0.8].map((t, vi) => {
|
||||||
|
const vx = dir * lx * t * 0.95
|
||||||
|
const vy = -ly * 0.12 * t
|
||||||
|
return (
|
||||||
|
<line key={vi} x1={vx} y1={vy}
|
||||||
|
x2={vx + dir * lx * 0.12} y2={vy - ly * (0.35 + vi * 0.08)}
|
||||||
|
stroke={c2} strokeWidth={0.55} opacity={0.45} />
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* ── Fruits on branches and stem ── */}
|
||||||
|
{plant.fruits.map(fruit => {
|
||||||
|
const leaf = plant.leaves.find(l => l.id === fruit.leafId)
|
||||||
|
if (!leaf) return null
|
||||||
|
|
||||||
|
let ox: number, oy: number
|
||||||
|
if (fruit.branchId != null) {
|
||||||
|
const branch = plant.branches.find(b => b.id === fruit.branchId)
|
||||||
|
if (!branch) return null
|
||||||
|
const tip = branchTip(branch)
|
||||||
|
ox = tip.tx
|
||||||
|
oy = tip.ty
|
||||||
|
} else {
|
||||||
|
const pt = stemPt(leaf.heightFraction)
|
||||||
|
ox = pt.x
|
||||||
|
oy = pt.y
|
||||||
|
}
|
||||||
|
|
||||||
|
const fSway = Math.sin(tick / 25 + fruit.swayOffset) * 3
|
||||||
|
const dir = fruit.side === 'left' ? -1 : 1
|
||||||
|
const fr = fruit.size * fruit.scale
|
||||||
|
const fx = ox + dir * (12 + fr) + fSway
|
||||||
|
const fy = oy + 4 * fruit.scale
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={`fr${fruit.id}`} filter='url(#fglow)' opacity={Math.min(fruit.scale * 1.3, 1)}>
|
||||||
|
{/* Hanging stem */}
|
||||||
|
<path d={`M${ox + dir * 3} ${oy} Q${(ox + fx) / 2} ${oy - 4} ${fx} ${fy - fr}`}
|
||||||
|
stroke='#4a7c42' strokeWidth={1} fill='none' strokeLinecap='round' />
|
||||||
|
{/* Fruit body */}
|
||||||
|
<circle cx={fx} cy={fy} r={fr} fill={fruit.color} />
|
||||||
|
{/* Depth gradient */}
|
||||||
|
<circle cx={fx + fr * 0.15} cy={fy + fr * 0.15} r={fr * 0.7}
|
||||||
|
fill='rgba(0,0,0,0.12)' />
|
||||||
|
{/* Shine */}
|
||||||
|
<circle cx={fx - fr * 0.25} cy={fy - fr * 0.3} r={fr * 0.3}
|
||||||
|
fill='white' opacity={0.4} />
|
||||||
|
{/* Calyx (small leaves on top) */}
|
||||||
|
<ellipse cx={fx - 2} cy={fy - fr - 1} rx={fr * 0.35} ry={fr * 0.15}
|
||||||
|
fill='#3d7a30' opacity={0.85} transform={`rotate(-15,${fx - 2},${fy - fr - 1})`} />
|
||||||
|
<ellipse cx={fx + 2} cy={fy - fr - 1} rx={fr * 0.35} ry={fr * 0.15}
|
||||||
|
fill='#4a8a3a' opacity={0.75} transform={`rotate(15,${fx + 2},${fy - fr - 1})`} />
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* ── Top bud → flower ── */}
|
||||||
|
{progress > 0.55 && (() => {
|
||||||
|
const bs = Math.min((progress - 0.55) / 0.3, 1)
|
||||||
|
const tx = s2x
|
||||||
|
const ty = s2y - 4
|
||||||
|
const ps = Math.sin(tick / 32) * 5
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g opacity={bs}>
|
||||||
|
{/* Sepals (green base petals) */}
|
||||||
|
{[0, 72, 144, 216, 288].map(deg => {
|
||||||
|
const r = ((deg + ps * 0.5) * Math.PI) / 180
|
||||||
|
return (
|
||||||
|
<ellipse key={`sep${deg}`}
|
||||||
|
cx={tx + Math.cos(r) * 7 * bs} cy={ty + Math.sin(r) * 7 * bs}
|
||||||
|
rx={4.5 * bs} ry={2 * bs} fill='#4a8c42' opacity={0.6}
|
||||||
|
transform={`rotate(${deg},${tx + Math.cos(r) * 7 * bs},${ty + Math.sin(r) * 7 * bs})`} />
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{/* Petals — larger, appear after 80% */}
|
||||||
|
{progress > 0.8 && [0, 45, 90, 135, 180, 225, 270, 315].map(deg => {
|
||||||
|
const petalScale = Math.min((progress - 0.8) / 0.15, 1)
|
||||||
|
const r = ((deg + ps) * Math.PI) / 180
|
||||||
|
const pd = 10 * petalScale * bs
|
||||||
|
return (
|
||||||
|
<ellipse key={`pet${deg}`}
|
||||||
|
cx={tx + Math.cos(r) * pd} cy={ty + Math.sin(r) * pd}
|
||||||
|
rx={5 * petalScale * bs} ry={2.8 * petalScale * bs}
|
||||||
|
fill={deg % 90 === 0 ? '#fde68a' : '#fcd34d'} opacity={0.9}
|
||||||
|
transform={`rotate(${deg + ps},${tx + Math.cos(r) * pd},${ty + Math.sin(r) * pd})`} />
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{/* Centre */}
|
||||||
|
<circle cx={tx} cy={ty} r={5.5 * bs} fill='#f9c74f' />
|
||||||
|
<circle cx={tx} cy={ty} r={3 * bs} fill='#f3722c' />
|
||||||
|
{/* Orbiting pollen */}
|
||||||
|
{progress > 0.88 && [0, 1, 2, 3, 4, 5].map(i => {
|
||||||
|
const a = (tick / 18 + i * 1.05) % (Math.PI * 2)
|
||||||
|
const or = 13 * bs
|
||||||
|
return <circle key={`pln${i}`} cx={tx + Math.cos(a) * or} cy={ty + Math.sin(a) * or}
|
||||||
|
r={1.3} fill='#fde68a' opacity={0.65} />
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Growth Chart ─────────────────────────────────────────────────────────────
|
// ─── Growth Chart ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function GrowthChart({
|
const CHART_OPTIONS = {
|
||||||
|
responsive: true,
|
||||||
|
animation: { duration: 0 },
|
||||||
|
plugins: {
|
||||||
|
legend: { labels: { color: '#e2e8f0', font: { size: 11 } } },
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'نمودار رشد گیاه',
|
||||||
|
color: '#e2e8f0',
|
||||||
|
font: { size: 14 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: { color: '#94a3b8', maxTicksLimit: 8 },
|
||||||
|
grid: { color: 'rgba(148,163,184,0.1)' }
|
||||||
|
},
|
||||||
|
yHeight: {
|
||||||
|
type: 'linear' as const,
|
||||||
|
position: 'left' as const,
|
||||||
|
min: 0,
|
||||||
|
max: MAX_HEIGHT,
|
||||||
|
ticks: { color: '#4a7c59' },
|
||||||
|
grid: { color: 'rgba(148,163,184,0.1)' },
|
||||||
|
title: { display: true, text: 'ارتفاع', color: '#4a7c59' }
|
||||||
|
},
|
||||||
|
yLeaf: {
|
||||||
|
type: 'linear' as const,
|
||||||
|
position: 'right' as const,
|
||||||
|
min: 0,
|
||||||
|
max: MAX_LEAVES,
|
||||||
|
ticks: { color: '#f9c74f' },
|
||||||
|
grid: { display: false },
|
||||||
|
title: { display: true, text: 'برگ', color: '#f9c74f' }
|
||||||
|
},
|
||||||
|
yYield: {
|
||||||
|
type: 'linear' as const,
|
||||||
|
position: 'left' as const,
|
||||||
|
min: 0,
|
||||||
|
max: MAX_YIELD,
|
||||||
|
display: false,
|
||||||
|
grid: { display: false }
|
||||||
|
},
|
||||||
|
yYieldRate: {
|
||||||
|
type: 'linear' as const,
|
||||||
|
position: 'right' as const,
|
||||||
|
min: 0,
|
||||||
|
display: false,
|
||||||
|
grid: { display: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const GrowthChart = memo(function GrowthChart({
|
||||||
heightHistory,
|
heightHistory,
|
||||||
leafHistory,
|
leafHistory,
|
||||||
yieldHistory,
|
yieldHistory,
|
||||||
@@ -196,70 +506,19 @@ function GrowthChart({
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = {
|
return <Line data={data} options={CHART_OPTIONS} />
|
||||||
responsive: true,
|
})
|
||||||
animation: { duration: 0 },
|
|
||||||
plugins: {
|
|
||||||
legend: { labels: { color: '#e2e8f0', font: { size: 11 } } },
|
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
text: 'نمودار رشد گیاه',
|
|
||||||
color: '#e2e8f0',
|
|
||||||
font: { size: 14 }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
ticks: { color: '#94a3b8', maxTicksLimit: 8 },
|
|
||||||
grid: { color: 'rgba(148,163,184,0.1)' }
|
|
||||||
},
|
|
||||||
yHeight: {
|
|
||||||
type: 'linear' as const,
|
|
||||||
position: 'left' as const,
|
|
||||||
min: 0,
|
|
||||||
max: MAX_HEIGHT,
|
|
||||||
ticks: { color: '#4a7c59' },
|
|
||||||
grid: { color: 'rgba(148,163,184,0.1)' },
|
|
||||||
title: { display: true, text: 'ارتفاع', color: '#4a7c59' }
|
|
||||||
},
|
|
||||||
yLeaf: {
|
|
||||||
type: 'linear' as const,
|
|
||||||
position: 'right' as const,
|
|
||||||
min: 0,
|
|
||||||
max: MAX_LEAVES,
|
|
||||||
ticks: { color: '#f9c74f' },
|
|
||||||
grid: { display: false },
|
|
||||||
title: { display: true, text: 'برگ', color: '#f9c74f' }
|
|
||||||
},
|
|
||||||
yYield: {
|
|
||||||
type: 'linear' as const,
|
|
||||||
position: 'left' as const,
|
|
||||||
min: 0,
|
|
||||||
max: MAX_YIELD,
|
|
||||||
display: false,
|
|
||||||
grid: { display: false }
|
|
||||||
},
|
|
||||||
yYieldRate: {
|
|
||||||
type: 'linear' as const,
|
|
||||||
position: 'right' as const,
|
|
||||||
min: 0,
|
|
||||||
display: false,
|
|
||||||
grid: { display: false }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Line data={data} options={options} />
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const INIT_PLANT: PlantState = { height: 0, leaves: [], branches: [], fruits: [], tick: 0, yield: 0, yieldRate: 0 }
|
||||||
|
|
||||||
export default function PlantSimulator() {
|
export default function PlantSimulator() {
|
||||||
const [running, setRunning] = useState(false)
|
const [running, setRunning] = useState(false)
|
||||||
const [speed, setSpeed] = useState(1.5) // px per tick base
|
const [speed, setSpeed] = useState(1.5)
|
||||||
const [env, setEnv] = useState<EnvironmentSettings>({ light: 75, water: 65 })
|
const [env, setEnv] = useState<EnvironmentSettings>({ light: 75, water: 65 })
|
||||||
|
|
||||||
const [plant, setPlant] = useState<PlantState>({ height: 0, leaves: [], tick: 0, yield: 0, yieldRate: 0 })
|
const [plant, setPlant] = useState<PlantState>(INIT_PLANT)
|
||||||
const [heightHistory, setHeightHistory] = useState<number[]>([0])
|
const [heightHistory, setHeightHistory] = useState<number[]>([0])
|
||||||
const [leafHistory, setLeafHistory] = useState<number[]>([0])
|
const [leafHistory, setLeafHistory] = useState<number[]>([0])
|
||||||
const [yieldHistory, setYieldHistory] = useState<number[]>([0])
|
const [yieldHistory, setYieldHistory] = useState<number[]>([0])
|
||||||
@@ -268,9 +527,11 @@ export default function PlantSimulator() {
|
|||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
const tickRef = useRef(0)
|
const tickRef = useRef(0)
|
||||||
const historyIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const historyIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
const plantRef = useRef<PlantState>(plant)
|
||||||
|
plantRef.current = plant
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
setPlant({ height: 0, leaves: [], tick: 0, yield: 0, yieldRate: 0 })
|
setPlant(INIT_PLANT)
|
||||||
setHeightHistory([0])
|
setHeightHistory([0])
|
||||||
setLeafHistory([0])
|
setLeafHistory([0])
|
||||||
setYieldHistory([0])
|
setYieldHistory([0])
|
||||||
@@ -278,7 +539,12 @@ export default function PlantSimulator() {
|
|||||||
tickRef.current = 0
|
tickRef.current = 0
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Main simulation tick at ~30fps
|
// Stop simulation when plant reaches max height
|
||||||
|
useEffect(() => {
|
||||||
|
if (plant.height >= MAX_HEIGHT && running) setRunning(false)
|
||||||
|
}, [plant.height, running])
|
||||||
|
|
||||||
|
// Main simulation tick at ~20fps (reduced from 30fps to prevent browser overload)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!running) {
|
if (!running) {
|
||||||
if (intervalRef.current) clearInterval(intervalRef.current)
|
if (intervalRef.current) clearInterval(intervalRef.current)
|
||||||
@@ -292,44 +558,127 @@ export default function PlantSimulator() {
|
|||||||
setPlant(prev => {
|
setPlant(prev => {
|
||||||
const rate = growthRate(env, speed)
|
const rate = growthRate(env, speed)
|
||||||
const newHeight = Math.min(prev.height + rate * 0.4, MAX_HEIGHT)
|
const newHeight = Math.min(prev.height + rate * 0.4, MAX_HEIGHT)
|
||||||
const newLeaves = [...prev.leaves]
|
const hp = newHeight / MAX_HEIGHT
|
||||||
|
|
||||||
// Add a new leaf every LEAF_INTERVAL_PX of stem growth
|
// ── Branches: appear after 30% growth ──
|
||||||
const expectedLeaves = Math.min(Math.floor(newHeight / LEAF_INTERVAL_PX), MAX_LEAVES)
|
const newBranches = [...prev.branches]
|
||||||
|
const expectedBranches = Math.min(Math.floor((hp - 0.3) / 0.12), MAX_BRANCHES)
|
||||||
while (newLeaves.length < expectedLeaves) {
|
while (newBranches.length < Math.max(0, expectedBranches)) {
|
||||||
const id = newLeaves.length
|
const bid = newBranches.length
|
||||||
newLeaves.push({
|
newBranches.push({
|
||||||
id,
|
id: bid,
|
||||||
side: id % 2 === 0 ? 'left' : 'right',
|
side: bid % 2 === 0 ? 'left' : 'right',
|
||||||
heightFraction: (id + 1) / (expectedLeaves + 1),
|
heightFraction: 0.25 + bid * 0.11,
|
||||||
angle: 0,
|
length: 30 + Math.random() * 25,
|
||||||
|
angle: -(25 + Math.random() * 20),
|
||||||
scale: 0,
|
scale: 0,
|
||||||
swayOffset: Math.random() * Math.PI * 2
|
swayOffset: Math.random() * Math.PI * 2,
|
||||||
|
thickness: 2.5 + Math.random() * 1.5
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const grownBranches = newBranches.map(b => ({
|
||||||
|
...b,
|
||||||
|
scale: Math.min(b.scale + 0.012, 1)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ── Leaves: on main stem + on branch tips ──
|
||||||
|
const newLeaves = [...prev.leaves]
|
||||||
|
const expectedStemLeaves = Math.min(Math.floor(newHeight / LEAF_INTERVAL_PX), MAX_LEAVES)
|
||||||
|
|
||||||
|
// Stem leaves (fixed: use 'added' counter; nextId-newLeaves.length was always 0 → infinite loop)
|
||||||
|
const stemLeafCount = newLeaves.filter(l => l.branchId === null).length
|
||||||
|
let nextId = newLeaves.length
|
||||||
|
let added = 0
|
||||||
|
while (stemLeafCount + added < expectedStemLeaves) {
|
||||||
|
const idx = stemLeafCount + added
|
||||||
|
newLeaves.push({
|
||||||
|
id: nextId,
|
||||||
|
side: idx % 2 === 0 ? 'left' : 'right',
|
||||||
|
heightFraction: (idx + 1) / (expectedStemLeaves + 1),
|
||||||
|
scale: 0,
|
||||||
|
swayOffset: Math.random() * Math.PI * 2,
|
||||||
|
length: 16 + Math.random() * 14,
|
||||||
|
branchId: null
|
||||||
|
})
|
||||||
|
nextId++
|
||||||
|
added++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Branch-tip leaves (1 per mature branch, once branch scale > 0.6)
|
||||||
|
for (const b of grownBranches) {
|
||||||
|
if (b.scale > 0.6) {
|
||||||
|
const hasLeaf = newLeaves.some(l => l.branchId === b.id)
|
||||||
|
if (!hasLeaf) {
|
||||||
|
newLeaves.push({
|
||||||
|
id: nextId++,
|
||||||
|
side: b.side,
|
||||||
|
heightFraction: b.heightFraction,
|
||||||
|
scale: 0,
|
||||||
|
swayOffset: Math.random() * Math.PI * 2,
|
||||||
|
length: 14 + Math.random() * 10,
|
||||||
|
branchId: b.id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const grownLeaves = newLeaves.map(l => ({
|
||||||
|
...l,
|
||||||
|
scale: Math.min(l.scale + 0.018, 1)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ── Yield ──
|
||||||
|
const currentYieldRate = computeYieldRate(env, grownLeaves.length, hp)
|
||||||
|
const newYield = Math.min(prev.yield + currentYieldRate * 0.033, MAX_YIELD)
|
||||||
|
|
||||||
|
// ── Fruits: appear after 45% on branches, then some on stem ──
|
||||||
|
const newFruits = [...prev.fruits]
|
||||||
|
const maxFruits = Math.min(Math.floor((hp - 0.45) / 0.08), grownBranches.length + 3)
|
||||||
|
let fNextId = newFruits.length
|
||||||
|
while (newFruits.length < Math.max(0, maxFruits)) {
|
||||||
|
const fid = fNextId++
|
||||||
|
// First fruits on branches, then on stem leaves
|
||||||
|
const onBranch = fid < grownBranches.length
|
||||||
|
const targetBranch = onBranch ? grownBranches[fid] : null
|
||||||
|
const targetLeaf = onBranch
|
||||||
|
? grownLeaves.find(l => l.branchId === targetBranch!.id) || grownLeaves[fid]
|
||||||
|
: grownLeaves[Math.min(fid, grownLeaves.length - 1)]
|
||||||
|
if (!targetLeaf) break
|
||||||
|
newFruits.push({
|
||||||
|
id: fid,
|
||||||
|
branchId: onBranch ? targetBranch!.id : null,
|
||||||
|
leafId: targetLeaf.id,
|
||||||
|
side: targetLeaf.side === 'left' ? 'right' : 'left',
|
||||||
|
scale: 0,
|
||||||
|
swayOffset: Math.random() * Math.PI * 2,
|
||||||
|
color: FRUIT_COLORS[fid % FRUIT_COLORS.length],
|
||||||
|
size: 5 + Math.random() * 4
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grow existing leaves (scale 0 → 1 over ~40 ticks)
|
const grownFruits = newFruits.map(f => ({
|
||||||
const grownLeaves = newLeaves.map(l => ({
|
...f,
|
||||||
...l,
|
scale: Math.min(f.scale + 0.014, 1)
|
||||||
scale: Math.min(l.scale + 0.025, 1)
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const heightProgress = newHeight / MAX_HEIGHT
|
return {
|
||||||
const currentYieldRate = computeYieldRate(env, grownLeaves.length, heightProgress)
|
height: newHeight,
|
||||||
// accumulate yield each tick (33ms ≈ 0.033s)
|
leaves: grownLeaves,
|
||||||
const newYield = Math.min(prev.yield + currentYieldRate * 0.033, MAX_YIELD)
|
branches: grownBranches,
|
||||||
|
fruits: grownFruits,
|
||||||
return { height: newHeight, leaves: grownLeaves, tick: t, yield: newYield, yieldRate: currentYieldRate }
|
tick: t,
|
||||||
|
yield: newYield,
|
||||||
|
yieldRate: currentYieldRate
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}, 33)
|
}, 50)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (intervalRef.current) clearInterval(intervalRef.current)
|
if (intervalRef.current) clearInterval(intervalRef.current)
|
||||||
}
|
}
|
||||||
}, [running, speed, env])
|
}, [running, speed, env])
|
||||||
|
|
||||||
// History logging every 1 second
|
// History logging every 1 second (reads from plantRef to avoid setPlant side-effects)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!running) {
|
if (!running) {
|
||||||
if (historyIntervalRef.current) clearInterval(historyIntervalRef.current)
|
if (historyIntervalRef.current) clearInterval(historyIntervalRef.current)
|
||||||
@@ -337,13 +686,11 @@ export default function PlantSimulator() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
historyIntervalRef.current = setInterval(() => {
|
historyIntervalRef.current = setInterval(() => {
|
||||||
setPlant(prev => {
|
const p = plantRef.current
|
||||||
setHeightHistory(h => [...h.slice(-59), Math.round(prev.height)])
|
setHeightHistory(h => [...h.slice(-59), Math.round(p.height)])
|
||||||
setLeafHistory(l => [...l.slice(-59), prev.leaves.length])
|
setLeafHistory(l => [...l.slice(-59), p.leaves.length])
|
||||||
setYieldHistory(y => [...y.slice(-59), parseFloat(prev.yield.toFixed(1))])
|
setYieldHistory(y => [...y.slice(-59), parseFloat(p.yield.toFixed(1))])
|
||||||
setYieldRateHistory(r => [...r.slice(-59), prev.yieldRate])
|
setYieldRateHistory(r => [...r.slice(-59), p.yieldRate])
|
||||||
return prev
|
|
||||||
})
|
|
||||||
}, 1000)
|
}, 1000)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -364,25 +711,33 @@ export default function PlantSimulator() {
|
|||||||
{/* ── Left: Plant visualization ── */}
|
{/* ── Left: Plant visualization ── */}
|
||||||
<div className='lg:col-span-1 flex flex-col items-center gap-4'>
|
<div className='lg:col-span-1 flex flex-col items-center gap-4'>
|
||||||
<div className='bg-slate-800 border border-slate-700 rounded-2xl p-6 w-full flex flex-col items-center shadow-xl'>
|
<div className='bg-slate-800 border border-slate-700 rounded-2xl p-6 w-full flex flex-col items-center shadow-xl'>
|
||||||
<PlantSVG plant={plant} tick={plant.tick} />
|
<PlantSVG plant={plant} tick={plant.tick} running={running} />
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className='mt-4 grid grid-cols-2 gap-3 w-full text-sm'>
|
<div className='mt-4 grid grid-cols-3 gap-2 w-full text-sm'>
|
||||||
<div className='bg-green-900/40 border border-green-700/40 rounded-xl p-3 text-center'>
|
<div className='bg-green-900/40 border border-green-700/40 rounded-xl p-2.5 text-center'>
|
||||||
<div className='text-green-400 font-semibold text-lg'>{Math.round(plant.height)}</div>
|
<div className='text-green-400 font-semibold text-base'>{Math.round(plant.height)}</div>
|
||||||
<div className='text-slate-400'>ارتفاع (px)</div>
|
<div className='text-slate-400 text-[10px]'>ارتفاع</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='bg-yellow-900/40 border border-yellow-700/40 rounded-xl p-3 text-center'>
|
<div className='bg-yellow-900/40 border border-yellow-700/40 rounded-xl p-2.5 text-center'>
|
||||||
<div className='text-yellow-400 font-semibold text-lg'>{plant.leaves.length}</div>
|
<div className='text-yellow-400 font-semibold text-base'>{plant.leaves.length}</div>
|
||||||
<div className='text-slate-400'>تعداد برگ</div>
|
<div className='text-slate-400 text-[10px]'>برگ</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='bg-orange-900/40 border border-orange-700/40 rounded-xl p-3 text-center'>
|
<div className='bg-emerald-900/40 border border-emerald-700/40 rounded-xl p-2.5 text-center'>
|
||||||
<div className='text-orange-400 font-semibold text-lg'>{plant.yield.toFixed(1)}</div>
|
<div className='text-emerald-400 font-semibold text-base'>{plant.branches.length}</div>
|
||||||
<div className='text-slate-400'>محصول (g)</div>
|
<div className='text-slate-400 text-[10px]'>شاخه</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='bg-violet-900/40 border border-violet-700/40 rounded-xl p-3 text-center'>
|
<div className='bg-red-900/40 border border-red-700/40 rounded-xl p-2.5 text-center'>
|
||||||
<div className='text-violet-400 font-semibold text-lg'>{plant.yieldRate.toFixed(3)}</div>
|
<div className='text-red-400 font-semibold text-base'>{plant.fruits.length}</div>
|
||||||
<div className='text-slate-400'>سرعت محصول (g/s)</div>
|
<div className='text-slate-400 text-[10px]'>میوه</div>
|
||||||
|
</div>
|
||||||
|
<div className='bg-orange-900/40 border border-orange-700/40 rounded-xl p-2.5 text-center'>
|
||||||
|
<div className='text-orange-400 font-semibold text-base'>{plant.yield.toFixed(1)}</div>
|
||||||
|
<div className='text-slate-400 text-[10px]'>محصول (g)</div>
|
||||||
|
</div>
|
||||||
|
<div className='bg-violet-900/40 border border-violet-700/40 rounded-xl p-2.5 text-center'>
|
||||||
|
<div className='text-violet-400 font-semibold text-base'>{plant.yieldRate.toFixed(2)}</div>
|
||||||
|
<div className='text-slate-400 text-[10px]'>سرعت (g/s)</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user