"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 (
);
}
// ─── 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)}×
);
}