Files
Frontend/src/views/dashboards/farm/plantSimulator/PlantSimulator.tsx
T

942 lines
35 KiB
TypeScript
Raw Normal View History

'use client'
import { useEffect, useRef, useState, useCallback, memo } from 'react'
import { useTranslations } from 'next-intl'
import {
Chart as ChartJS,
LineElement,
PointElement,
LinearScale,
CategoryScale,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js'
import { Line } from 'react-chartjs-2'
import Card from '@mui/material/Card'
import CardContent from '@mui/material/CardContent'
import Typography from '@mui/material/Typography'
import Box from '@mui/material/Box'
import Grid from '@mui/material/Grid2'
import Button from '@mui/material/Button'
ChartJS.register(LineElement, PointElement, LinearScale, CategoryScale, Title, Tooltip, Legend, Filler)
// ─── Types ───────────────────────────────────────────────────────────────────
interface Leaf {
id: number
side: 'left' | 'right'
heightFraction: number
scale: number
swayOffset: number
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 {
height: number
leaves: Leaf[]
branches: Branch[]
fruits: Fruit[]
tick: number
yield: number
yieldRate: number
}
interface EnvironmentSettings {
light: number
water: number
}
// ─── Constants ───────────────────────────────────────────────────────────────
const MAX_HEIGHT = 280
const MAX_YIELD = 500
const SVG_W = 280
const SVG_H = 400
const STEM_X = SVG_W / 2
const BASE_Y = SVG_H - 24
const LEAF_INTERVAL_PX = 30
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 ────────────────
function growthRate(env: EnvironmentSettings, speed: number): number {
const lightFactor = 0.3 + (env.light / 100) * 0.7
const waterFactor = 0.3 + (env.water / 100) * 0.7
return speed * lightFactor * waterFactor
}
// Yield rate in g/s: leaves and height progress amplify yield production
function computeYieldRate(env: EnvironmentSettings, leafCount: number, heightProgress: number): number {
const lightFactor = 0.2 + (env.light / 100) * 0.8
const waterFactor = 0.2 + (env.water / 100) * 0.8
const leafFactor = leafCount / MAX_LEAVES
// yield only starts after 20% growth and accelerates with more leaves
const maturityFactor = Math.max(0, (heightProgress - 0.2) / 0.8)
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 ──────────────────────────────────────────────────────
function PlantSVG({ plant, tick, running }: { plant: PlantState; tick: number; running: boolean }) {
const stemTop = BASE_Y - 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 (
<svg width={SVG_W} height={SVG_H} viewBox={`0 0 ${SVG_W} ${SVG_H}`} className='drop-shadow-2xl'>
<defs>
<linearGradient id='sg' x1='0' y1='1' x2='0' y2='0'>
<stop offset='0%' stopColor='#24462e' />
<stop offset='50%' stopColor='#3d7a4f' />
<stop offset='100%' stopColor='#6ab870' />
</linearGradient>
<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>
{/* ── Floating pollen particles ── */}
{running && progress > 0.35 && [0, 1, 2, 3, 4, 5].map(i => {
const px = STEM_X + Math.sin(tick / 28 + i * 1.1) * 40
const cycle = (tick * 0.35 + i * 30) % 80
const py = stemTop - 12 - cycle
return <circle key={`p${i}`} cx={px} cy={py} r={1.4} fill='#fde68a' opacity={Math.max(0, 0.55 - cycle / 160)} />
})}
{/* ── Soil mound ── */}
<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>
)}
{/* ── Main stem (quadratic bezier) ── */}
{plant.height > 0 && (
<>
<path d={`M${s0x} ${s0y} Q${s1x} ${s1y} ${s2x} ${s2y}`}
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 (
<g key={`br${b.id}`} opacity={b.scale}>
<path d={`M${bx} ${by} Q${cx} ${cy} ${tx} ${ty}`}
stroke='url(#bg)' strokeWidth={bw} fill='none' strokeLinecap='round' />
{/* branch highlight */}
<path d={`M${bx} ${by} Q${cx - 0.5} ${cy - 0.5} ${tx} ${ty}`}
stroke='rgba(150,210,130,0.15)' strokeWidth={bw * 0.4} fill='none' strokeLinecap='round' />
{/* small thorn/bud at tip */}
<circle cx={tx} cy={ty} r={bw * 0.5 + 0.5} fill='#5a9a52' opacity={0.7} />
</g>
)
})}
{/* ── Leaves ── */}
{plant.leaves.map(leaf => {
let ax: number, ay: number
if (leaf.branchId != null) {
const branch = plant.branches.find(b => b.id === leaf.branchId)
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>
)
}
// ─── Growth Chart ─────────────────────────────────────────────────────────────
const GrowthChart = memo(function GrowthChart({
heightHistory,
leafHistory,
yieldHistory,
yieldRateHistory
}: {
heightHistory: number[]
leafHistory: number[]
yieldHistory: number[]
yieldRateHistory: number[]
}) {
const t = useTranslations('plantSimulator')
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 0 },
plugins: {
legend: { labels: { font: { size: 11 } } },
title: {
display: true,
text: t('chartTitle'),
font: { size: 14 }
}
},
scales: {
x: {
ticks: { maxTicksLimit: 8 }
},
yHeight: {
type: 'linear' as const,
position: 'left' as const,
min: 0,
max: MAX_HEIGHT,
title: { display: true, text: t('chartHeight') }
},
yLeaf: {
type: 'linear' as const,
position: 'right' as const,
min: 0,
max: MAX_LEAVES,
grid: { display: false },
title: { display: true, text: t('chartLeaves') }
},
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 labels = heightHistory.map((_, i) => `${i}s`)
const data = {
labels,
datasets: [
{
label: t('chartHeightPx'),
data: heightHistory,
borderColor: '#4a7c59',
backgroundColor: 'rgba(74,124,89,0.10)',
fill: true,
tension: 0.4,
pointRadius: 0,
yAxisID: 'yHeight'
},
{
label: t('chartLeafCount'),
data: leafHistory,
borderColor: '#f9c74f',
backgroundColor: 'rgba(249,199,79,0.10)',
fill: true,
tension: 0.4,
pointRadius: 0,
yAxisID: 'yLeaf'
},
{
label: t('chartYield'),
data: yieldHistory,
borderColor: '#f97316',
backgroundColor: 'rgba(249,115,22,0.10)',
fill: true,
tension: 0.4,
pointRadius: 0,
yAxisID: 'yYield'
},
{
label: t('chartYieldRate'),
data: yieldRateHistory,
borderColor: '#a78bfa',
backgroundColor: 'rgba(167,139,250,0.10)',
fill: true,
tension: 0.4,
pointRadius: 0,
borderDash: [4, 3],
yAxisID: 'yYieldRate'
}
]
}
return (
<Box
className='is-full'
sx={{ minHeight: { xs: 380, sm: 340, md: 320 }, height: { xs: 380, sm: 340, md: 320 } }}
>
<Line data={data} options={chartOptions} />
</Box>
)
})
// ─── Main Component ───────────────────────────────────────────────────────────
const INIT_PLANT: PlantState = { height: 0, leaves: [], branches: [], fruits: [], tick: 0, yield: 0, yieldRate: 0 }
export default function PlantSimulator() {
const [running, setRunning] = useState(false)
const [speed, setSpeed] = useState(1.5)
const [env, setEnv] = useState<EnvironmentSettings>({ light: 75, water: 65 })
const [plant, setPlant] = useState<PlantState>(INIT_PLANT)
const [heightHistory, setHeightHistory] = useState<number[]>([0])
const [leafHistory, setLeafHistory] = useState<number[]>([0])
const [yieldHistory, setYieldHistory] = useState<number[]>([0])
const [yieldRateHistory, setYieldRateHistory] = useState<number[]>([0])
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const tickRef = useRef(0)
const historyIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const plantRef = useRef<PlantState>(plant)
plantRef.current = plant
const reset = useCallback(() => {
setPlant(INIT_PLANT)
setHeightHistory([0])
setLeafHistory([0])
setYieldHistory([0])
setYieldRateHistory([0])
tickRef.current = 0
}, [])
// 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(() => {
if (!running) {
if (intervalRef.current) clearInterval(intervalRef.current)
return
}
intervalRef.current = setInterval(() => {
tickRef.current += 1
const t = tickRef.current
setPlant(prev => {
const rate = growthRate(env, speed)
const newHeight = Math.min(prev.height + rate * 0.4, MAX_HEIGHT)
const hp = newHeight / MAX_HEIGHT
// ── Branches: appear after 30% growth ──
const newBranches = [...prev.branches]
const expectedBranches = Math.min(Math.floor((hp - 0.3) / 0.12), MAX_BRANCHES)
while (newBranches.length < Math.max(0, expectedBranches)) {
const bid = newBranches.length
newBranches.push({
id: bid,
side: bid % 2 === 0 ? 'left' : 'right',
heightFraction: 0.25 + bid * 0.11,
length: 30 + Math.random() * 25,
angle: -(25 + Math.random() * 20),
scale: 0,
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
})
}
const grownFruits = newFruits.map(f => ({
...f,
scale: Math.min(f.scale + 0.014, 1)
}))
return {
height: newHeight,
leaves: grownLeaves,
branches: grownBranches,
fruits: grownFruits,
tick: t,
yield: newYield,
yieldRate: currentYieldRate
}
})
}, 50)
return () => {
if (intervalRef.current) clearInterval(intervalRef.current)
}
}, [running, speed, env])
// History logging every 1 second (reads from plantRef to avoid setPlant side-effects)
useEffect(() => {
if (!running) {
if (historyIntervalRef.current) clearInterval(historyIntervalRef.current)
return
}
historyIntervalRef.current = setInterval(() => {
const p = plantRef.current
setHeightHistory(h => [...h.slice(-59), Math.round(p.height)])
setLeafHistory(l => [...l.slice(-59), p.leaves.length])
setYieldHistory(y => [...y.slice(-59), parseFloat(p.yield.toFixed(1))])
setYieldRateHistory(r => [...r.slice(-59), p.yieldRate])
}, 1000)
return () => {
if (historyIntervalRef.current) clearInterval(historyIntervalRef.current)
}
}, [running])
const t = useTranslations('plantSimulator')
const isFinished = plant.height >= MAX_HEIGHT
const statItems: { value: string | number; label: string; color: 'success' | 'warning' | 'error' | 'secondary' }[] = [
{ value: Math.round(plant.height), label: t('height'), color: 'success' },
{ value: plant.leaves.length, label: t('leaves'), color: 'warning' },
{ value: plant.branches.length, label: t('branches'), color: 'success' },
{ value: plant.fruits.length, label: t('fruits'), color: 'error' },
{ value: plant.yield.toFixed(1), label: t('yield'), color: 'warning' },
{ value: plant.yieldRate.toFixed(2), label: t('yieldRate'), color: 'secondary' }
]
return (
<Box className='flex flex-col gap-6 min-is-0 is-full'>
<Typography variant='h4' className='text-center font-bold'>
🌱 {t('title')}
</Typography>
<Grid container spacing={6} className='min-is-0 is-full'>
{/* ── Left: Plant visualization ── */}
<Grid size={{ xs: 12, lg: 4 }} className='flex flex-col items-center gap-4'>
<Card className='is-full flex flex-col items-center p-6'>
<CardContent className='flex flex-col items-center gap-4 is-full p-0'>
<PlantSVG plant={plant} tick={plant.tick} running={running} />
<Grid container spacing={2} className='is-full'>
{statItems.map((item, idx) => (
<Grid key={idx} size={{ xs: 4 }}>
<Card variant='outlined' className='text-center p-2.5'>
<Typography variant='h6' color={item.color}>
{item.value}
</Typography>
<Typography variant='caption' color='text.secondary'>
{item.label}
</Typography>
</Card>
</Grid>
))}
</Grid>
{isFinished && (
<Typography variant='body2' color='warning.main' className='font-medium animate-pulse'>
🌼 {t('maxGrowthReached')}
</Typography>
)}
</CardContent>
</Card>
{/* Controls */}
<Card className='is-full p-5'>
<CardContent className='space-y-4 p-0'>
<Typography variant='subtitle1' component='h2' className='font-semibold'>
{t('controls')}
</Typography>
<Box className='flex gap-2'>
<Button
variant='contained'
color={running ? 'error' : 'success'}
onClick={() => setRunning(r => !r)}
disabled={isFinished}
fullWidth
>
{running ? `${t('stop')}` : `${t('start')}`}
</Button>
<Button
variant='outlined'
color='secondary'
onClick={() => { setRunning(false); reset() }}
>
{t('reset')}
</Button>
</Box>
<Box>
<Typography variant='body2' color='text.secondary' className='flex justify-between mbe-1'>
<span>{t('growthSpeed')}</span>
<span className='font-medium'>{speed.toFixed(1)}×</span>
</Typography>
<input
type='range'
min={0.5}
max={5}
step={0.5}
value={speed}
onChange={e => setSpeed(Number(e.target.value))}
className='w-full'
/>
</Box>
<Box>
<Typography variant='body2' color='text.secondary' className='flex justify-between mbe-1'>
<span> {t('light')}</span>
<span className='font-medium'>{env.light}%</span>
</Typography>
<input
type='range'
min={0}
max={100}
step={5}
value={env.light}
onChange={e => setEnv(prev => ({ ...prev, light: Number(e.target.value) }))}
className='w-full'
/>
</Box>
<Box>
<Typography variant='body2' color='text.secondary' className='flex justify-between mbe-1'>
<span>💧 {t('water')}</span>
<span className='font-medium'>{env.water}%</span>
</Typography>
<input
type='range'
min={0}
max={100}
step={5}
value={env.water}
onChange={e => setEnv(prev => ({ ...prev, water: Number(e.target.value) }))}
className='w-full'
/>
</Box>
<Card variant='outlined' className='p-3'>
<Typography variant='caption' color='text.secondary'>
{t('effectiveRate')}{' '}
<Typography component='span' variant='caption' color='success.main' fontWeight='bold'>
{growthRate(env, speed).toFixed(2)}×
</Typography>
</Typography>
</Card>
</CardContent>
</Card>
</Grid>
{/* ── Right: Chart ── */}
<Grid size={{ xs: 12, lg: 8 }}>
<Card className='p-6'>
<CardContent>
<GrowthChart
heightHistory={heightHistory}
leafHistory={leafHistory}
yieldHistory={yieldHistory}
yieldRateHistory={yieldRateHistory}
/>
<Grid container spacing={4} className='mt-6'>
<Grid size={{ xs: 12, sm: 6, lg: 3 }}>
<Card variant='outlined' className='p-4'>
<Typography variant='body2' color='text.secondary' className='mbe-1'>
{t('progressGrowth')}
</Typography>
<Box className='w-full rounded-full overflow-hidden bg-action-hover mbe-1' sx={{ height: 8 }}>
<Box
className='bg-success rounded-full h-full transition-all'
sx={{ width: `${(plant.height / MAX_HEIGHT) * 100}%` }}
/>
</Box>
<Typography variant='body2' color='success.main' fontWeight='bold'>
{Math.round((plant.height / MAX_HEIGHT) * 100)}%
</Typography>
</Card>
</Grid>
<Grid size={{ xs: 12, sm: 6, lg: 3 }}>
<Card variant='outlined' className='p-4'>
<Typography variant='body2' color='text.secondary' className='mbe-1'>
{t('lightStatus')}
</Typography>
<Box className='w-full rounded-full overflow-hidden bg-action-hover mbe-1' sx={{ height: 8 }}>
<Box
className='bg-warning rounded-full h-full transition-all'
sx={{ width: `${env.light}%` }}
/>
</Box>
<Typography variant='body2' color='warning.main' fontWeight='bold'>
{env.light}%
</Typography>
</Card>
</Grid>
<Grid size={{ xs: 12, sm: 6, lg: 3 }}>
<Card variant='outlined' className='p-4'>
<Typography variant='body2' color='text.secondary' className='mbe-1'>
{t('waterStatus')}
</Typography>
<Box className='w-full rounded-full overflow-hidden bg-action-hover mbe-1' sx={{ height: 8 }}>
<Box
className='bg-info rounded-full h-full transition-all'
sx={{ width: `${env.water}%` }}
/>
</Box>
<Typography variant='body2' color='info.main' fontWeight='bold'>
{env.water}%
</Typography>
</Card>
</Grid>
<Grid size={{ xs: 12, sm: 6, lg: 3 }}>
<Card variant='outlined' className='p-4'>
<Typography variant='body2' color='text.secondary' className='mbe-1'>
{t('yieldStatus')}
</Typography>
<Box className='w-full rounded-full overflow-hidden bg-action-hover mbe-1' sx={{ height: 8 }}>
<Box
className='bg-warning rounded-full h-full transition-all'
sx={{ width: `${(plant.yield / MAX_YIELD) * 100}%` }}
/>
</Box>
<Box className='flex justify-between items-center'>
<Typography variant='body2' color='warning.main' fontWeight='bold'>
{plant.yield.toFixed(1)}g
</Typography>
<Typography variant='caption' color='secondary.main'>
{plant.yieldRate.toFixed(3)} g/s
</Typography>
</Box>
</Card>
</Grid>
</Grid>
<Card variant='outlined' className='mt-6 p-4'>
<Typography variant='body2' color='text.secondary' className='leading-6'>
{t('description')}
</Typography>
</Card>
</CardContent>
</Card>
</Grid>
</Grid>
</Box>
)
}