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

1272 lines
40 KiB
TypeScript
Raw Normal View History

2026-04-29 22:26:53 +03:30
"use client";
2026-04-29 22:26:53 +03:30
import { useEffect, useRef, useState, useCallback, memo } from "react";
import { useTranslations } from "next-intl";
import {
Chart as ChartJS,
LineElement,
PointElement,
LinearScale,
CategoryScale,
Title,
Tooltip,
Legend,
2026-04-29 22:26:53 +03:30
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 {
2026-04-29 22:26:53 +03:30
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 {
2026-04-29 22:26:53 +03:30
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 {
2026-04-29 22:26:53 +03:30
id: number;
branchId: number | null;
leafId: number;
side: "left" | "right";
scale: number;
swayOffset: number;
color: string;
size: number; // varied fruit radius
}
interface PlantState {
2026-04-29 22:26:53 +03:30
height: number;
leaves: Leaf[];
branches: Branch[];
fruits: Fruit[];
tick: number;
yield: number;
yieldRate: number;
}
interface EnvironmentSettings {
2026-04-29 22:26:53 +03:30
light: number;
water: number;
}
// ─── Constants ───────────────────────────────────────────────────────────────
2026-04-29 22:26:53 +03:30
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 {
2026-04-29 22:26:53 +03:30
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
2026-04-29 22:26:53 +03:30
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
2026-04-29 22:26:53 +03:30
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) {
2026-04-29 22:26:53 +03:30
return (1 - t) * (1 - t) * p0 + 2 * (1 - t) * t * p1 + t * t * p2;
}
function qBezTan(p0: number, p1: number, p2: number, t: number) {
2026-04-29 22:26:53 +03:30
return 2 * (1 - t) * (p1 - p0) + 2 * t * (p2 - p1);
}
// ─── Plant SVG Component ──────────────────────────────────────────────────────
2026-04-29 22:26:53 +03:30
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);
2026-04-29 22:26:53 +03:30
const sway = Math.sin(tick / 55) * 3;
// Stem control points — quadratic Bezier
2026-04-29 22:26:53 +03:30
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),
2026-04-29 22:26:53 +03:30
y: qBez(s0y, s1y, s2y, t),
});
// Get angle on main stem at t
const stemAngle = (t: number) => {
2026-04-29 22:26:53 +03:30
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) => {
2026-04-29 22:26:53 +03:30
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,
2026-04-29 22:26:53 +03:30
cy: base.y + Math.sin(rad) * len * 0.35 - 8 * b.scale,
};
};
return (
2026-04-29 22:26:53 +03:30
<svg
width={SVG_W}
height={SVG_H}
viewBox={`0 0 ${SVG_W} ${SVG_H}`}
className="drop-shadow-2xl"
>
<defs>
2026-04-29 22:26:53 +03:30
<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>
2026-04-29 22:26:53 +03:30
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#4a8c5a" />
<stop offset="100%" stopColor="#2e5a38" />
</linearGradient>
2026-04-29 22:26:53 +03:30
<radialGradient id="soilG" cx="50%" cy="40%" r="60%">
<stop offset="0%" stopColor="#b08030" />
<stop offset="100%" stopColor="#4a2c0a" />
</radialGradient>
2026-04-29 22:26:53 +03:30
<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>
2026-04-29 22:26:53 +03:30
<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 ── */}
2026-04-29 22:26:53 +03:30
{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 ── */}
2026-04-29 22:26:53 +03:30
<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) => (
2026-04-29 22:26:53 +03:30
<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}`}
2026-04-29 22:26:53 +03:30
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 },
2026-04-29 22:26:53 +03:30
{ dx: 12, dy: 18, w: 1, cx: 6, cy: 14 },
].map((r, i) => (
2026-04-29 22:26:53 +03:30
<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}`}
2026-04-29 22:26:53 +03:30
stroke="#3b2a12"
strokeWidth={r.w}
fill="none"
strokeLinecap="round"
/>
))}
</g>
)}
{/* ── Main stem (quadratic bezier) ── */}
{plant.height > 0 && (
<>
2026-04-29 22:26:53 +03:30
<path
d={`M${s0x} ${s0y} Q${s1x} ${s1y} ${s2x} ${s2y}`}
stroke="url(#sg)"
strokeWidth={stemW}
fill="none"
strokeLinecap="round"
/>
{/* Stem highlight vein */}
2026-04-29 22:26:53 +03:30
<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 */}
2026-04-29 22:26:53 +03:30
{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) ── */}
2026-04-29 22:26:53 +03:30
{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}>
2026-04-29 22:26:53 +03:30
<path
d={`M${bx} ${by} Q${cx} ${cy} ${tx} ${ty}`}
stroke="url(#bg)"
strokeWidth={bw}
fill="none"
strokeLinecap="round"
/>
{/* branch highlight */}
2026-04-29 22:26:53 +03:30
<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 */}
2026-04-29 22:26:53 +03:30
<circle
cx={tx}
cy={ty}
r={bw * 0.5 + 0.5}
fill="#5a9a52"
opacity={0.7}
/>
</g>
2026-04-29 22:26:53 +03:30
);
})}
{/* ── Leaves ── */}
2026-04-29 22:26:53 +03:30
{plant.leaves.map((leaf) => {
let ax: number, ay: number;
if (leaf.branchId != null) {
2026-04-29 22:26:53 +03:30
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 {
2026-04-29 22:26:53 +03:30
const pt = stemPt(leaf.heightFraction);
ax = pt.x;
ay = pt.y;
}
2026-04-29 22:26:53 +03:30
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 (
2026-04-29 22:26:53 +03:30
<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`}
2026-04-29 22:26:53 +03:30
fill={c1}
/>
{/* Midrib */}
2026-04-29 22:26:53 +03:30
<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) => {
2026-04-29 22:26:53 +03:30
const vx = dir * lx * t * 0.95;
const vy = -ly * 0.12 * t;
return (
2026-04-29 22:26:53 +03:30
<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>
2026-04-29 22:26:53 +03:30
);
})}
{/* ── Fruits on branches and stem ── */}
2026-04-29 22:26:53 +03:30
{plant.fruits.map((fruit) => {
const leaf = plant.leaves.find((l) => l.id === fruit.leafId);
if (!leaf) return null;
2026-04-29 22:26:53 +03:30
let ox: number, oy: number;
if (fruit.branchId != null) {
2026-04-29 22:26:53 +03:30
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 {
2026-04-29 22:26:53 +03:30
const pt = stemPt(leaf.heightFraction);
ox = pt.x;
oy = pt.y;
}
2026-04-29 22:26:53 +03:30
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 (
2026-04-29 22:26:53 +03:30
<g
key={`fr${fruit.id}`}
filter="url(#fglow)"
opacity={Math.min(fruit.scale * 1.3, 1)}
>
{/* Hanging stem */}
2026-04-29 22:26:53 +03:30
<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 */}
2026-04-29 22:26:53 +03:30
<circle
cx={fx + fr * 0.15}
cy={fy + fr * 0.15}
r={fr * 0.7}
fill="rgba(0,0,0,0.12)"
/>
{/* Shine */}
2026-04-29 22:26:53 +03:30
<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) */}
2026-04-29 22:26:53 +03:30
<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>
2026-04-29 22:26:53 +03:30
);
})}
{/* ── Top bud → flower ── */}
2026-04-29 22:26:53 +03:30
{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>
2026-04-29 22:26:53 +03:30
);
}
// ─── Growth Chart ─────────────────────────────────────────────────────────────
const GrowthChart = memo(function GrowthChart({
heightHistory,
leafHistory,
yieldHistory,
2026-04-29 22:26:53 +03:30
yieldRateHistory,
}: {
2026-04-29 22:26:53 +03:30
heightHistory: number[];
leafHistory: number[];
yieldHistory: number[];
yieldRateHistory: number[];
}) {
2026-04-29 22:26:53 +03:30
const t = useTranslations("plantSimulator");
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 0 },
plugins: {
legend: { labels: { font: { size: 11 } } },
title: {
display: true,
2026-04-29 22:26:53 +03:30
text: t("chartTitle"),
font: { size: 14 },
},
},
scales: {
x: {
2026-04-29 22:26:53 +03:30
ticks: { maxTicksLimit: 8 },
},
yHeight: {
2026-04-29 22:26:53 +03:30
type: "linear" as const,
position: "left" as const,
min: 0,
max: MAX_HEIGHT,
2026-04-29 22:26:53 +03:30
title: { display: true, text: t("chartHeight") },
},
yLeaf: {
2026-04-29 22:26:53 +03:30
type: "linear" as const,
position: "right" as const,
min: 0,
max: MAX_LEAVES,
grid: { display: false },
2026-04-29 22:26:53 +03:30
title: { display: true, text: t("chartLeaves") },
},
yYield: {
2026-04-29 22:26:53 +03:30
type: "linear" as const,
position: "left" as const,
min: 0,
max: MAX_YIELD,
display: false,
2026-04-29 22:26:53 +03:30
grid: { display: false },
},
yYieldRate: {
2026-04-29 22:26:53 +03:30
type: "linear" as const,
position: "right" as const,
min: 0,
display: false,
2026-04-29 22:26:53 +03:30
grid: { display: false },
},
},
};
2026-04-29 22:26:53 +03:30
const labels = heightHistory.map((_, i) => `${i}s`);
const data = {
labels,
datasets: [
{
2026-04-29 22:26:53 +03:30
label: t("chartHeightPx"),
data: heightHistory,
2026-04-29 22:26:53 +03:30
borderColor: "#4a7c59",
backgroundColor: "rgba(74,124,89,0.10)",
fill: true,
tension: 0.4,
pointRadius: 0,
2026-04-29 22:26:53 +03:30
yAxisID: "yHeight",
},
{
2026-04-29 22:26:53 +03:30
label: t("chartLeafCount"),
data: leafHistory,
2026-04-29 22:26:53 +03:30
borderColor: "#f9c74f",
backgroundColor: "rgba(249,199,79,0.10)",
fill: true,
tension: 0.4,
pointRadius: 0,
2026-04-29 22:26:53 +03:30
yAxisID: "yLeaf",
},
{
2026-04-29 22:26:53 +03:30
label: t("chartYield"),
data: yieldHistory,
2026-04-29 22:26:53 +03:30
borderColor: "#f97316",
backgroundColor: "rgba(249,115,22,0.10)",
fill: true,
tension: 0.4,
pointRadius: 0,
2026-04-29 22:26:53 +03:30
yAxisID: "yYield",
},
{
2026-04-29 22:26:53 +03:30
label: t("chartYieldRate"),
data: yieldRateHistory,
2026-04-29 22:26:53 +03:30
borderColor: "#a78bfa",
backgroundColor: "rgba(167,139,250,0.10)",
fill: true,
tension: 0.4,
pointRadius: 0,
borderDash: [4, 3],
2026-04-29 22:26:53 +03:30
yAxisID: "yYieldRate",
},
],
};
return (
<Box
2026-04-29 22:26:53 +03:30
className="is-full"
sx={{
minHeight: { xs: 380, sm: 340, md: 320 },
height: { xs: 380, sm: 340, md: 320 },
}}
>
<Line data={data} options={chartOptions} />
</Box>
2026-04-29 22:26:53 +03:30
);
});
// ─── Main Component ───────────────────────────────────────────────────────────
2026-04-29 22:26:53 +03:30
const INIT_PLANT: PlantState = {
height: 0,
leaves: [],
branches: [],
fruits: [],
tick: 0,
yield: 0,
yieldRate: 0,
};
export default function PlantSimulator() {
2026-04-29 22:26:53 +03:30
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(() => {
2026-04-29 22:26:53 +03:30
setPlant(INIT_PLANT);
setHeightHistory([0]);
setLeafHistory([0]);
setYieldHistory([0]);
setYieldRateHistory([0]);
tickRef.current = 0;
}, []);
// Stop simulation when plant reaches max height
useEffect(() => {
2026-04-29 22:26:53 +03:30
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) {
2026-04-29 22:26:53 +03:30
if (intervalRef.current) clearInterval(intervalRef.current);
return;
}
intervalRef.current = setInterval(() => {
2026-04-29 22:26:53 +03:30
tickRef.current += 1;
const t = tickRef.current;
2026-04-29 22:26:53 +03:30
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 ──
2026-04-29 22:26:53 +03:30
const newBranches = [...prev.branches];
const expectedBranches = Math.min(
Math.floor((hp - 0.3) / 0.12),
MAX_BRANCHES,
);
while (newBranches.length < Math.max(0, expectedBranches)) {
2026-04-29 22:26:53 +03:30
const bid = newBranches.length;
newBranches.push({
id: bid,
2026-04-29 22:26:53 +03:30
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,
2026-04-29 22:26:53 +03:30
thickness: 2.5 + Math.random() * 1.5,
});
}
2026-04-29 22:26:53 +03:30
const grownBranches = newBranches.map((b) => ({
...b,
2026-04-29 22:26:53 +03:30
scale: Math.min(b.scale + 0.012, 1),
}));
// ── Leaves: on main stem + on branch tips ──
2026-04-29 22:26:53 +03:30
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)
2026-04-29 22:26:53 +03:30
const stemLeafCount = newLeaves.filter(
(l) => l.branchId === null,
).length;
let nextId = newLeaves.length;
let added = 0;
while (stemLeafCount + added < expectedStemLeaves) {
2026-04-29 22:26:53 +03:30
const idx = stemLeafCount + added;
newLeaves.push({
id: nextId,
2026-04-29 22:26:53 +03:30
side: idx % 2 === 0 ? "left" : "right",
heightFraction: (idx + 1) / (expectedStemLeaves + 1),
scale: 0,
swayOffset: Math.random() * Math.PI * 2,
length: 16 + Math.random() * 14,
2026-04-29 22:26:53 +03:30
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) {
2026-04-29 22:26:53 +03:30
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,
2026-04-29 22:26:53 +03:30
branchId: b.id,
});
}
}
}
2026-04-29 22:26:53 +03:30
const grownLeaves = newLeaves.map((l) => ({
...l,
2026-04-29 22:26:53 +03:30
scale: Math.min(l.scale + 0.018, 1),
}));
// ── Yield ──
2026-04-29 22:26:53 +03:30
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 ──
2026-04-29 22:26:53 +03:30
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)) {
2026-04-29 22:26:53 +03:30
const fid = fNextId++;
// First fruits on branches, then on stem leaves
2026-04-29 22:26:53 +03:30
const onBranch = fid < grownBranches.length;
const targetBranch = onBranch ? grownBranches[fid] : null;
const targetLeaf = onBranch
2026-04-29 22:26:53 +03:30
? 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,
2026-04-29 22:26:53 +03:30
side: targetLeaf.side === "left" ? "right" : "left",
scale: 0,
swayOffset: Math.random() * Math.PI * 2,
color: FRUIT_COLORS[fid % FRUIT_COLORS.length],
2026-04-29 22:26:53 +03:30
size: 5 + Math.random() * 4,
});
}
2026-04-29 22:26:53 +03:30
const grownFruits = newFruits.map((f) => ({
...f,
2026-04-29 22:26:53 +03:30
scale: Math.min(f.scale + 0.014, 1),
}));
return {
height: newHeight,
leaves: grownLeaves,
branches: grownBranches,
fruits: grownFruits,
tick: t,
yield: newYield,
2026-04-29 22:26:53 +03:30
yieldRate: currentYieldRate,
};
});
}, 50);
return () => {
2026-04-29 22:26:53 +03:30
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) {
2026-04-29 22:26:53 +03:30
if (historyIntervalRef.current) clearInterval(historyIntervalRef.current);
return;
}
historyIntervalRef.current = setInterval(() => {
2026-04-29 22:26:53 +03:30
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 () => {
2026-04-29 22:26:53 +03:30
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",
},
];
2026-04-29 22:26:53 +03:30
const primaryPanelSx = {
height: "100%",
minHeight: { xs: "auto", lg: 780 },
};
return (
2026-04-29 22:26:53 +03:30
<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} />
2026-04-29 22:26:53 +03:30
<Grid container spacing={2} className="is-full">
{statItems.map((item, idx) => (
<Grid key={idx} size={{ xs: 4 }}>
2026-04-29 22:26:53 +03:30
<Card variant="outlined" className="text-center p-2.5">
<Typography variant="h6" color={item.color}>
{item.value}
</Typography>
2026-04-29 22:26:53 +03:30
<Typography variant="caption" color="text.secondary">
{item.label}
</Typography>
</Card>
</Grid>
))}
</Grid>
{isFinished && (
2026-04-29 22:26:53 +03:30
<Typography
variant="body2"
color="warning.main"
className="font-medium animate-pulse"
>
2026-04-29 22:26:53 +03:30
🌼 {t("maxGrowthReached")}
</Typography>
2026-04-29 22:26:53 +03:30
)}
</CardContent>
</Card>
</Grid>
2026-04-29 22:26:53 +03:30
{/* ── 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}
/>
2026-04-29 22:26:53 +03:30
<Grid container spacing={4} className="mt-6">
<Grid size={{ xs: 12, sm: 6, lg: 3 }}>
2026-04-29 22:26:53 +03:30
<Card variant="outlined" className="p-4">
<Typography
variant="body2"
color="text.secondary"
className="mbe-1"
>
{t("progressGrowth")}
</Typography>
2026-04-29 22:26:53 +03:30
<Box
className="w-full rounded-full overflow-hidden bg-action-hover mbe-1"
sx={{ height: 8 }}
>
<Box
2026-04-29 22:26:53 +03:30
className="bg-success rounded-full h-full transition-all"
sx={{ width: `${(plant.height / MAX_HEIGHT) * 100}%` }}
/>
</Box>
2026-04-29 22:26:53 +03:30
<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 }}>
2026-04-29 22:26:53 +03:30
<Card variant="outlined" className="p-4">
<Typography
variant="body2"
color="text.secondary"
className="mbe-1"
>
{t("lightStatus")}
</Typography>
2026-04-29 22:26:53 +03:30
<Box
className="w-full rounded-full overflow-hidden bg-action-hover mbe-1"
sx={{ height: 8 }}
>
<Box
2026-04-29 22:26:53 +03:30
className="bg-warning rounded-full h-full transition-all"
sx={{ width: `${env.light}%` }}
/>
</Box>
2026-04-29 22:26:53 +03:30
<Typography
variant="body2"
color="warning.main"
fontWeight="bold"
>
{env.light}%
</Typography>
</Card>
</Grid>
<Grid size={{ xs: 12, sm: 6, lg: 3 }}>
2026-04-29 22:26:53 +03:30
<Card variant="outlined" className="p-4">
<Typography
variant="body2"
color="text.secondary"
className="mbe-1"
>
{t("waterStatus")}
</Typography>
2026-04-29 22:26:53 +03:30
<Box
className="w-full rounded-full overflow-hidden bg-action-hover mbe-1"
sx={{ height: 8 }}
>
<Box
2026-04-29 22:26:53 +03:30
className="bg-info rounded-full h-full transition-all"
sx={{ width: `${env.water}%` }}
/>
</Box>
2026-04-29 22:26:53 +03:30
<Typography
variant="body2"
color="info.main"
fontWeight="bold"
>
{env.water}%
</Typography>
</Card>
</Grid>
<Grid size={{ xs: 12, sm: 6, lg: 3 }}>
2026-04-29 22:26:53 +03:30
<Card variant="outlined" className="p-4">
<Typography
variant="body2"
color="text.secondary"
className="mbe-1"
>
{t("yieldStatus")}
</Typography>
2026-04-29 22:26:53 +03:30
<Box
className="w-full rounded-full overflow-hidden bg-action-hover mbe-1"
sx={{ height: 8 }}
>
<Box
2026-04-29 22:26:53 +03:30
className="bg-warning rounded-full h-full transition-all"
sx={{ width: `${(plant.yield / MAX_YIELD) * 100}%` }}
/>
</Box>
2026-04-29 22:26:53 +03:30
<Box className="flex justify-between items-center">
<Typography
variant="body2"
color="warning.main"
fontWeight="bold"
>
{plant.yield.toFixed(1)}g
</Typography>
2026-04-29 22:26:53 +03:30
<Typography variant="caption" color="secondary.main">
{plant.yieldRate.toFixed(3)} g/s
</Typography>
</Box>
</Card>
</Grid>
</Grid>
2026-04-29 22:26:53 +03:30
<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>
2026-04-29 22:26:53 +03:30
);
}