Files
Frontend/src/views/dashboards/farm/plantSimulator/PlantSimulator.tsx
T
2026-04-29 22:26:53 +03:30

1272 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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",
},
];
const primaryPanelSx = {
height: "100%",
minHeight: { xs: "auto", lg: 780 },
};
return (
<Box className="flex flex-col gap-6 min-is-0 is-full">
<Grid container spacing={6} className="min-is-0 is-full">
{/* ── Plant visualization ── */}
<Grid size={{ xs: 12, lg: 6 }} className="flex">
<Card
className="is-full flex flex-col items-center p-6"
sx={primaryPanelSx}
>
<CardContent
className="flex flex-col items-center gap-4 is-full p-0"
sx={{ flex: 1 }}
>
<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>
</Grid>
{/* ── Chart ── */}
<Grid size={{ xs: 12, lg: 6 }} className="flex">
<Card className="p-6 flex flex-col" sx={primaryPanelSx}>
<CardContent sx={{ flex: 1 }}>
<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>
<Grid container spacing={6} className="min-is-0 is-full">
{/* ── Controls ── */}
<Grid size={12} className="flex">
<Card className="is-full p-5" sx={{ width: "100%" }}>
<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>
</Grid>
</Box>
);
}