"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 ( {/* ── 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 ( ); })} {/* ── Soil mound ── */} {[-24, -10, 4, 16, 28].map((dx) => ( ))} {/* Small grass tufts */} {[-30, -14, 18, 32].map((dx, i) => ( ))} {/* ── Roots ── */} {plant.height > 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) => ( ))} )} {/* ── Main stem (quadratic bezier) ── */} {plant.height > 0 && ( <> {/* Stem highlight vein */} {/* Stem node bumps — small circles at branch attachment points */} {plant.branches.map((b) => { const pt = stemPt(b.heightFraction); return ( ); })} )} {/* ── Branches (curved) ── */} {plant.branches.map((b) => { const { bx, by, tx, ty, cx, cy } = branchTip(b); const bw = b.thickness * b.scale; return ( {/* branch highlight */} {/* small thorn/bud at tip */} ); })} {/* ── 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 ( {/* Leaf shape — custom path for more natural look */} {/* Midrib */} {/* 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 ( ); })} ); })} {/* ── 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 ( {/* Hanging stem */} {/* Fruit body */} {/* Depth gradient */} {/* Shine */} {/* Calyx (small leaves on top) */} ); })} {/* ── 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 ( {/* Sepals (green base petals) */} {[0, 72, 144, 216, 288].map((deg) => { const r = ((deg + ps * 0.5) * Math.PI) / 180; return ( ); })} {/* 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 ( ); })} {/* Centre */} {/* 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 ( ); })} ); })()} ); } // ─── 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 ( ); }); // ─── 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({ light: 75, water: 65 }); const [plant, setPlant] = useState(INIT_PLANT); const [heightHistory, setHeightHistory] = useState([0]); const [leafHistory, setLeafHistory] = useState([0]); const [yieldHistory, setYieldHistory] = useState([0]); const [yieldRateHistory, setYieldRateHistory] = useState([0]); const intervalRef = useRef | null>(null); const tickRef = useRef(0); const historyIntervalRef = useRef | null>( null, ); const plantRef = useRef(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", }, ]; const primaryPanelSx = { height: "100%", minHeight: { xs: "auto", lg: 780 }, }; return ( {/* ── Plant visualization ── */} {statItems.map((item, idx) => ( {item.value} {item.label} ))} {isFinished && ( 🌼 {t("maxGrowthReached")} )} {/* ── Chart ── */} {t("progressGrowth")} {Math.round((plant.height / MAX_HEIGHT) * 100)}% {t("lightStatus")} {env.light}% {t("waterStatus")} {env.water}% {t("yieldStatus")} {plant.yield.toFixed(1)}g {plant.yieldRate.toFixed(3)} g/s {t("description")} {/* ── Controls ── */} {t("controls")} {t("growthSpeed")} {speed.toFixed(1)}× setSpeed(Number(e.target.value))} className="w-full" /> ☀️ {t("light")} {env.light}% setEnv((prev) => ({ ...prev, light: Number(e.target.value), })) } className="w-full" /> 💧 {t("water")} {env.water}% setEnv((prev) => ({ ...prev, water: Number(e.target.value), })) } className="w-full" /> {t("effectiveRate")}{" "} {growthRate(env, speed).toFixed(2)}× ); }