2026-04-29 22:26:53 +03:30
|
|
|
|
"use client";
|
2026-02-20 23:08:44 +03:30
|
|
|
|
|
2026-04-29 22:26:53 +03:30
|
|
|
|
import { useEffect, useRef, useState, useCallback, memo } from "react";
|
|
|
|
|
|
import { useTranslations } from "next-intl";
|
2026-02-20 23:08:44 +03:30
|
|
|
|
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,
|
|
|
|
|
|
);
|
2026-02-20 23:08:44 +03:30
|
|
|
|
|
|
|
|
|
|
// ─── 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
|
2026-02-20 23:35:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-02-20 23:35:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-02-20 23:08:44 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface PlantState {
|
2026-04-29 22:26:53 +03:30
|
|
|
|
height: number;
|
|
|
|
|
|
leaves: Leaf[];
|
|
|
|
|
|
branches: Branch[];
|
|
|
|
|
|
fruits: Fruit[];
|
|
|
|
|
|
tick: number;
|
|
|
|
|
|
yield: number;
|
|
|
|
|
|
yieldRate: number;
|
2026-02-20 23:08:44 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface EnvironmentSettings {
|
2026-04-29 22:26:53 +03:30
|
|
|
|
light: number;
|
|
|
|
|
|
water: number;
|
2026-02-20 23:08:44 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── 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",
|
|
|
|
|
|
];
|
2026-02-20 23:08:44 +03:30
|
|
|
|
|
|
|
|
|
|
// ─── 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;
|
2026-02-20 23:08:44 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 23:15:29 +03:30
|
|
|
|
// 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;
|
2026-02-20 23:15:29 +03:30
|
|
|
|
// 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),
|
|
|
|
|
|
);
|
2026-02-20 23:15:29 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 23:35:30 +03:30
|
|
|
|
// ─── 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;
|
2026-02-20 23:35:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-02-20 23:35:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 23:08:44 +03:30
|
|
|
|
// ─── 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-02-20 23:35:30 +03:30
|
|
|
|
|
2026-04-29 22:26:53 +03:30
|
|
|
|
const sway = Math.sin(tick / 55) * 3;
|
2026-02-20 23:35:30 +03:30
|
|
|
|
|
|
|
|
|
|
// 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;
|
2026-02-20 23:35:30 +03:30
|
|
|
|
|
|
|
|
|
|
// 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),
|
|
|
|
|
|
});
|
2026-02-20 23:35:30 +03:30
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
};
|
2026-02-20 23:35:30 +03:30
|
|
|
|
|
|
|
|
|
|
// 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;
|
2026-02-20 23:35:30 +03:30
|
|
|
|
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,
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
2026-02-20 23:08:44 +03:30
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
>
|
2026-02-20 23:35:30 +03:30
|
|
|
|
<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" />
|
2026-02-20 23:35:30 +03:30
|
|
|
|
</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" />
|
2026-02-20 23:35:30 +03:30
|
|
|
|
</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" />
|
2026-02-20 23:35:30 +03:30
|
|
|
|
</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>
|
2026-02-20 23:35:30 +03:30
|
|
|
|
</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"
|
|
|
|
|
|
/>
|
2026-02-20 23:35:30 +03:30
|
|
|
|
</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)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
2026-02-20 23:35:30 +03:30
|
|
|
|
|
|
|
|
|
|
{/* ── 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}
|
|
|
|
|
|
/>
|
2026-02-20 23:35:30 +03:30
|
|
|
|
))}
|
|
|
|
|
|
{/* Small grass tufts */}
|
|
|
|
|
|
{[-30, -14, 18, 32].map((dx, i) => (
|
2026-04-29 22:26:53 +03:30
|
|
|
|
<path
|
|
|
|
|
|
key={`grass${i}`}
|
2026-02-20 23:35:30 +03:30
|
|
|
|
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}
|
|
|
|
|
|
/>
|
2026-02-20 23:35:30 +03:30
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── 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 },
|
2026-02-20 23:35:30 +03:30
|
|
|
|
].map((r, i) => (
|
2026-04-29 22:26:53 +03:30
|
|
|
|
<path
|
|
|
|
|
|
key={`root${i}`}
|
2026-02-20 23:35:30 +03:30
|
|
|
|
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"
|
|
|
|
|
|
/>
|
2026-02-20 23:35:30 +03:30
|
|
|
|
))}
|
|
|
|
|
|
</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"
|
|
|
|
|
|
/>
|
2026-02-20 23:35:30 +03:30
|
|
|
|
{/* 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"
|
|
|
|
|
|
/>
|
2026-02-20 23:35:30 +03:30
|
|
|
|
{/* 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}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
2026-02-20 23:35:30 +03:30
|
|
|
|
})}
|
|
|
|
|
|
</>
|
2026-02-20 23:08:44 +03:30
|
|
|
|
)}
|
|
|
|
|
|
|
2026-02-20 23:35:30 +03:30
|
|
|
|
{/* ── 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;
|
2026-02-20 23:35:30 +03:30
|
|
|
|
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"
|
|
|
|
|
|
/>
|
2026-02-20 23:35:30 +03:30
|
|
|
|
{/* 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"
|
|
|
|
|
|
/>
|
2026-02-20 23:35:30 +03:30
|
|
|
|
{/* 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}
|
|
|
|
|
|
/>
|
2026-02-20 23:35:30 +03:30
|
|
|
|
</g>
|
2026-04-29 22:26:53 +03:30
|
|
|
|
);
|
2026-02-20 23:35:30 +03:30
|
|
|
|
})}
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── Leaves ── */}
|
2026-04-29 22:26:53 +03:30
|
|
|
|
{plant.leaves.map((leaf) => {
|
|
|
|
|
|
let ax: number, ay: number;
|
2026-02-20 23:35:30 +03:30
|
|
|
|
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;
|
2026-02-20 23:35:30 +03:30
|
|
|
|
} else {
|
2026-04-29 22:26:53 +03:30
|
|
|
|
const pt = stemPt(leaf.heightFraction);
|
|
|
|
|
|
ax = pt.x;
|
|
|
|
|
|
ay = pt.y;
|
2026-02-20 23:35:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
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}%)`;
|
2026-02-20 23:08:44 +03:30
|
|
|
|
|
|
|
|
|
|
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)}
|
|
|
|
|
|
>
|
2026-02-20 23:35:30 +03:30
|
|
|
|
{/* 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}
|
|
|
|
|
|
/>
|
2026-02-20 23:35:30 +03:30
|
|
|
|
{/* 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}
|
|
|
|
|
|
/>
|
2026-02-20 23:35:30 +03:30
|
|
|
|
{/* 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;
|
2026-02-20 23:35:30 +03:30
|
|
|
|
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}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
2026-02-20 23:35:30 +03:30
|
|
|
|
})}
|
2026-02-20 23:08:44 +03:30
|
|
|
|
</g>
|
2026-04-29 22:26:53 +03:30
|
|
|
|
);
|
2026-02-20 23:08:44 +03:30
|
|
|
|
})}
|
|
|
|
|
|
|
2026-02-20 23:35:30 +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-02-20 23:35:30 +03:30
|
|
|
|
|
2026-04-29 22:26:53 +03:30
|
|
|
|
let ox: number, oy: number;
|
2026-02-20 23:35:30 +03:30
|
|
|
|
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;
|
2026-02-20 23:35:30 +03:30
|
|
|
|
} else {
|
2026-04-29 22:26:53 +03:30
|
|
|
|
const pt = stemPt(leaf.heightFraction);
|
|
|
|
|
|
ox = pt.x;
|
|
|
|
|
|
oy = pt.y;
|
2026-02-20 23:35:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
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;
|
2026-02-20 23:35:30 +03:30
|
|
|
|
|
|
|
|
|
|
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)}
|
|
|
|
|
|
>
|
2026-02-20 23:35:30 +03:30
|
|
|
|
{/* 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"
|
|
|
|
|
|
/>
|
2026-02-20 23:35:30 +03:30
|
|
|
|
{/* 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)"
|
|
|
|
|
|
/>
|
2026-02-20 23:35:30 +03:30
|
|
|
|
{/* 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}
|
|
|
|
|
|
/>
|
2026-02-20 23:35:30 +03:30
|
|
|
|
{/* 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})`}
|
|
|
|
|
|
/>
|
2026-02-20 23:35:30 +03:30
|
|
|
|
</g>
|
2026-04-29 22:26:53 +03:30
|
|
|
|
);
|
2026-02-20 23:35:30 +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>
|
|
|
|
|
|
);
|
|
|
|
|
|
})()}
|
2026-02-20 23:08:44 +03:30
|
|
|
|
</svg>
|
2026-04-29 22:26:53 +03:30
|
|
|
|
);
|
2026-02-20 23:08:44 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Growth Chart ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-02-20 23:35:30 +03:30
|
|
|
|
const GrowthChart = memo(function GrowthChart({
|
2026-02-20 23:08:44 +03:30
|
|
|
|
heightHistory,
|
2026-02-20 23:15:29 +03:30
|
|
|
|
leafHistory,
|
|
|
|
|
|
yieldHistory,
|
2026-04-29 22:26:53 +03:30
|
|
|
|
yieldRateHistory,
|
2026-02-20 23:08:44 +03:30
|
|
|
|
}: {
|
2026-04-29 22:26:53 +03:30
|
|
|
|
heightHistory: number[];
|
|
|
|
|
|
leafHistory: number[];
|
|
|
|
|
|
yieldHistory: number[];
|
|
|
|
|
|
yieldRateHistory: number[];
|
2026-02-20 23:08:44 +03:30
|
|
|
|
}) {
|
2026-04-29 22:26:53 +03:30
|
|
|
|
const t = useTranslations("plantSimulator");
|
2026-02-20 23:48:14 +03:30
|
|
|
|
|
|
|
|
|
|
const chartOptions = {
|
|
|
|
|
|
responsive: true,
|
2026-02-21 22:05:47 +03:30
|
|
|
|
maintainAspectRatio: false,
|
2026-02-20 23:48:14 +03:30
|
|
|
|
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 },
|
|
|
|
|
|
},
|
2026-02-20 23:48:14 +03:30
|
|
|
|
},
|
|
|
|
|
|
scales: {
|
|
|
|
|
|
x: {
|
2026-04-29 22:26:53 +03:30
|
|
|
|
ticks: { maxTicksLimit: 8 },
|
2026-02-20 23:48:14 +03:30
|
|
|
|
},
|
|
|
|
|
|
yHeight: {
|
2026-04-29 22:26:53 +03:30
|
|
|
|
type: "linear" as const,
|
|
|
|
|
|
position: "left" as const,
|
2026-02-20 23:48:14 +03:30
|
|
|
|
min: 0,
|
|
|
|
|
|
max: MAX_HEIGHT,
|
2026-04-29 22:26:53 +03:30
|
|
|
|
title: { display: true, text: t("chartHeight") },
|
2026-02-20 23:48:14 +03:30
|
|
|
|
},
|
|
|
|
|
|
yLeaf: {
|
2026-04-29 22:26:53 +03:30
|
|
|
|
type: "linear" as const,
|
|
|
|
|
|
position: "right" as const,
|
2026-02-20 23:48:14 +03:30
|
|
|
|
min: 0,
|
|
|
|
|
|
max: MAX_LEAVES,
|
|
|
|
|
|
grid: { display: false },
|
2026-04-29 22:26:53 +03:30
|
|
|
|
title: { display: true, text: t("chartLeaves") },
|
2026-02-20 23:48:14 +03:30
|
|
|
|
},
|
|
|
|
|
|
yYield: {
|
2026-04-29 22:26:53 +03:30
|
|
|
|
type: "linear" as const,
|
|
|
|
|
|
position: "left" as const,
|
2026-02-20 23:48:14 +03:30
|
|
|
|
min: 0,
|
|
|
|
|
|
max: MAX_YIELD,
|
|
|
|
|
|
display: false,
|
2026-04-29 22:26:53 +03:30
|
|
|
|
grid: { display: false },
|
2026-02-20 23:48:14 +03:30
|
|
|
|
},
|
|
|
|
|
|
yYieldRate: {
|
2026-04-29 22:26:53 +03:30
|
|
|
|
type: "linear" as const,
|
|
|
|
|
|
position: "right" as const,
|
2026-02-20 23:48:14 +03:30
|
|
|
|
min: 0,
|
|
|
|
|
|
display: false,
|
2026-04-29 22:26:53 +03:30
|
|
|
|
grid: { display: false },
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
2026-02-20 23:48:14 +03:30
|
|
|
|
|
2026-04-29 22:26:53 +03:30
|
|
|
|
const labels = heightHistory.map((_, i) => `${i}s`);
|
2026-02-20 23:08:44 +03:30
|
|
|
|
|
|
|
|
|
|
const data = {
|
|
|
|
|
|
labels,
|
|
|
|
|
|
datasets: [
|
|
|
|
|
|
{
|
2026-04-29 22:26:53 +03:30
|
|
|
|
label: t("chartHeightPx"),
|
2026-02-20 23:08:44 +03:30
|
|
|
|
data: heightHistory,
|
2026-04-29 22:26:53 +03:30
|
|
|
|
borderColor: "#4a7c59",
|
|
|
|
|
|
backgroundColor: "rgba(74,124,89,0.10)",
|
2026-02-20 23:08:44 +03:30
|
|
|
|
fill: true,
|
|
|
|
|
|
tension: 0.4,
|
|
|
|
|
|
pointRadius: 0,
|
2026-04-29 22:26:53 +03:30
|
|
|
|
yAxisID: "yHeight",
|
2026-02-20 23:08:44 +03:30
|
|
|
|
},
|
|
|
|
|
|
{
|
2026-04-29 22:26:53 +03:30
|
|
|
|
label: t("chartLeafCount"),
|
2026-02-20 23:08:44 +03:30
|
|
|
|
data: leafHistory,
|
2026-04-29 22:26:53 +03:30
|
|
|
|
borderColor: "#f9c74f",
|
|
|
|
|
|
backgroundColor: "rgba(249,199,79,0.10)",
|
2026-02-20 23:08:44 +03:30
|
|
|
|
fill: true,
|
|
|
|
|
|
tension: 0.4,
|
|
|
|
|
|
pointRadius: 0,
|
2026-04-29 22:26:53 +03:30
|
|
|
|
yAxisID: "yLeaf",
|
2026-02-20 23:15:29 +03:30
|
|
|
|
},
|
|
|
|
|
|
{
|
2026-04-29 22:26:53 +03:30
|
|
|
|
label: t("chartYield"),
|
2026-02-20 23:15:29 +03:30
|
|
|
|
data: yieldHistory,
|
2026-04-29 22:26:53 +03:30
|
|
|
|
borderColor: "#f97316",
|
|
|
|
|
|
backgroundColor: "rgba(249,115,22,0.10)",
|
2026-02-20 23:15:29 +03:30
|
|
|
|
fill: true,
|
|
|
|
|
|
tension: 0.4,
|
|
|
|
|
|
pointRadius: 0,
|
2026-04-29 22:26:53 +03:30
|
|
|
|
yAxisID: "yYield",
|
2026-02-20 23:15:29 +03:30
|
|
|
|
},
|
|
|
|
|
|
{
|
2026-04-29 22:26:53 +03:30
|
|
|
|
label: t("chartYieldRate"),
|
2026-02-20 23:15:29 +03:30
|
|
|
|
data: yieldRateHistory,
|
2026-04-29 22:26:53 +03:30
|
|
|
|
borderColor: "#a78bfa",
|
|
|
|
|
|
backgroundColor: "rgba(167,139,250,0.10)",
|
2026-02-20 23:15:29 +03:30
|
|
|
|
fill: true,
|
|
|
|
|
|
tension: 0.4,
|
|
|
|
|
|
pointRadius: 0,
|
|
|
|
|
|
borderDash: [4, 3],
|
2026-04-29 22:26:53 +03:30
|
|
|
|
yAxisID: "yYieldRate",
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
2026-02-20 23:08:44 +03:30
|
|
|
|
|
2026-02-21 22:05:47 +03:30
|
|
|
|
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 },
|
|
|
|
|
|
}}
|
2026-02-21 22:05:47 +03:30
|
|
|
|
>
|
|
|
|
|
|
<Line data={data} options={chartOptions} />
|
|
|
|
|
|
</Box>
|
2026-04-29 22:26:53 +03:30
|
|
|
|
);
|
|
|
|
|
|
});
|
2026-02-20 23:08:44 +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,
|
|
|
|
|
|
};
|
2026-02-20 23:35:30 +03:30
|
|
|
|
|
2026-02-20 23:08:44 +03:30
|
|
|
|
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;
|
2026-02-20 23:08:44 +03:30
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}, []);
|
2026-02-20 23:08:44 +03:30
|
|
|
|
|
2026-02-20 23:35:30 +03:30
|
|
|
|
// 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]);
|
2026-02-20 23:35:30 +03:30
|
|
|
|
|
|
|
|
|
|
// Main simulation tick at ~20fps (reduced from 30fps to prevent browser overload)
|
2026-02-20 23:08:44 +03:30
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!running) {
|
2026-04-29 22:26:53 +03:30
|
|
|
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
|
|
|
|
return;
|
2026-02-20 23:08:44 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
intervalRef.current = setInterval(() => {
|
2026-04-29 22:26:53 +03:30
|
|
|
|
tickRef.current += 1;
|
|
|
|
|
|
const t = tickRef.current;
|
2026-02-20 23:08:44 +03:30
|
|
|
|
|
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;
|
2026-02-20 23:35:30 +03:30
|
|
|
|
|
|
|
|
|
|
// ── 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,
|
|
|
|
|
|
);
|
2026-02-20 23:35:30 +03:30
|
|
|
|
while (newBranches.length < Math.max(0, expectedBranches)) {
|
2026-04-29 22:26:53 +03:30
|
|
|
|
const bid = newBranches.length;
|
2026-02-20 23:35:30 +03:30
|
|
|
|
newBranches.push({
|
|
|
|
|
|
id: bid,
|
2026-04-29 22:26:53 +03:30
|
|
|
|
side: bid % 2 === 0 ? "left" : "right",
|
2026-02-20 23:35:30 +03:30
|
|
|
|
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-02-20 23:35:30 +03:30
|
|
|
|
}
|
2026-04-29 22:26:53 +03:30
|
|
|
|
const grownBranches = newBranches.map((b) => ({
|
2026-02-20 23:35:30 +03:30
|
|
|
|
...b,
|
2026-04-29 22:26:53 +03:30
|
|
|
|
scale: Math.min(b.scale + 0.012, 1),
|
|
|
|
|
|
}));
|
2026-02-20 23:08:44 +03:30
|
|
|
|
|
2026-02-20 23:35:30 +03:30
|
|
|
|
// ── 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,
|
|
|
|
|
|
);
|
2026-02-20 23:35:30 +03:30
|
|
|
|
|
|
|
|
|
|
// 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;
|
2026-02-20 23:35:30 +03:30
|
|
|
|
while (stemLeafCount + added < expectedStemLeaves) {
|
2026-04-29 22:26:53 +03:30
|
|
|
|
const idx = stemLeafCount + added;
|
2026-02-20 23:08:44 +03:30
|
|
|
|
newLeaves.push({
|
2026-02-20 23:35:30 +03:30
|
|
|
|
id: nextId,
|
2026-04-29 22:26:53 +03:30
|
|
|
|
side: idx % 2 === 0 ? "left" : "right",
|
2026-02-20 23:35:30 +03:30
|
|
|
|
heightFraction: (idx + 1) / (expectedStemLeaves + 1),
|
2026-02-20 23:08:44 +03:30
|
|
|
|
scale: 0,
|
2026-02-20 23:35:30 +03:30
|
|
|
|
swayOffset: Math.random() * Math.PI * 2,
|
|
|
|
|
|
length: 16 + Math.random() * 14,
|
2026-04-29 22:26:53 +03:30
|
|
|
|
branchId: null,
|
|
|
|
|
|
});
|
|
|
|
|
|
nextId++;
|
|
|
|
|
|
added++;
|
2026-02-20 23:35:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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);
|
2026-02-20 23:35:30 +03:30
|
|
|
|
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-02-20 23:35:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-20 23:08:44 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 22:26:53 +03:30
|
|
|
|
const grownLeaves = newLeaves.map((l) => ({
|
2026-02-20 23:08:44 +03:30
|
|
|
|
...l,
|
2026-04-29 22:26:53 +03:30
|
|
|
|
scale: Math.min(l.scale + 0.018, 1),
|
|
|
|
|
|
}));
|
2026-02-20 23:08:44 +03:30
|
|
|
|
|
2026-02-20 23:35:30 +03:30
|
|
|
|
// ── 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,
|
|
|
|
|
|
);
|
2026-02-20 23:15:29 +03:30
|
|
|
|
|
2026-02-20 23:35:30 +03:30
|
|
|
|
// ── 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;
|
2026-02-20 23:35:30 +03:30
|
|
|
|
while (newFruits.length < Math.max(0, maxFruits)) {
|
2026-04-29 22:26:53 +03:30
|
|
|
|
const fid = fNextId++;
|
2026-02-20 23:35:30 +03:30
|
|
|
|
// 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;
|
2026-02-20 23:35:30 +03:30
|
|
|
|
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;
|
2026-02-20 23:35:30 +03:30
|
|
|
|
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",
|
2026-02-20 23:35:30 +03:30
|
|
|
|
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-02-20 23:35:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 22:26:53 +03:30
|
|
|
|
const grownFruits = newFruits.map((f) => ({
|
2026-02-20 23:35:30 +03:30
|
|
|
|
...f,
|
2026-04-29 22:26:53 +03:30
|
|
|
|
scale: Math.min(f.scale + 0.014, 1),
|
|
|
|
|
|
}));
|
2026-02-20 23:35:30 +03:30
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
height: newHeight,
|
|
|
|
|
|
leaves: grownLeaves,
|
|
|
|
|
|
branches: grownBranches,
|
|
|
|
|
|
fruits: grownFruits,
|
|
|
|
|
|
tick: t,
|
|
|
|
|
|
yield: newYield,
|
2026-04-29 22:26:53 +03:30
|
|
|
|
yieldRate: currentYieldRate,
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
}, 50);
|
2026-02-20 23:08:44 +03:30
|
|
|
|
|
|
|
|
|
|
return () => {
|
2026-04-29 22:26:53 +03:30
|
|
|
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [running, speed, env]);
|
2026-02-20 23:08:44 +03:30
|
|
|
|
|
2026-02-20 23:35:30 +03:30
|
|
|
|
// History logging every 1 second (reads from plantRef to avoid setPlant side-effects)
|
2026-02-20 23:08:44 +03:30
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!running) {
|
2026-04-29 22:26:53 +03:30
|
|
|
|
if (historyIntervalRef.current) clearInterval(historyIntervalRef.current);
|
|
|
|
|
|
return;
|
2026-02-20 23:08:44 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-02-20 23:08:44 +03:30
|
|
|
|
|
|
|
|
|
|
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-02-20 23:08:44 +03:30
|
|
|
|
|
2026-04-29 22:26:53 +03:30
|
|
|
|
const primaryPanelSx = {
|
|
|
|
|
|
height: "100%",
|
|
|
|
|
|
minHeight: { xs: "auto", lg: 780 },
|
|
|
|
|
|
};
|
2026-02-20 23:08:44 +03:30
|
|
|
|
|
2026-02-20 23:48:14 +03:30
|
|
|
|
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 }}
|
|
|
|
|
|
>
|
2026-02-20 23:48:14 +03:30
|
|
|
|
<PlantSVG plant={plant} tick={plant.tick} running={running} />
|
|
|
|
|
|
|
2026-04-29 22:26:53 +03:30
|
|
|
|
<Grid container spacing={2} className="is-full">
|
2026-02-20 23:48:14 +03:30
|
|
|
|
{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}>
|
2026-02-20 23:48:14 +03:30
|
|
|
|
{item.value}
|
|
|
|
|
|
</Typography>
|
2026-04-29 22:26:53 +03:30
|
|
|
|
<Typography variant="caption" color="text.secondary">
|
2026-02-20 23:48:14 +03:30
|
|
|
|
{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-02-20 23:48:14 +03:30
|
|
|
|
>
|
2026-04-29 22:26:53 +03:30
|
|
|
|
🌼 {t("maxGrowthReached")}
|
2026-02-20 23:48:14 +03:30
|
|
|
|
</Typography>
|
2026-04-29 22:26:53 +03:30
|
|
|
|
)}
|
2026-02-20 23:48:14 +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 }}>
|
2026-02-20 23:48:14 +03:30
|
|
|
|
<GrowthChart
|
|
|
|
|
|
heightHistory={heightHistory}
|
|
|
|
|
|
leafHistory={leafHistory}
|
|
|
|
|
|
yieldHistory={yieldHistory}
|
|
|
|
|
|
yieldRateHistory={yieldRateHistory}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
2026-04-29 22:26:53 +03:30
|
|
|
|
<Grid container spacing={4} className="mt-6">
|
2026-02-20 23:48:14 +03:30
|
|
|
|
<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")}
|
2026-02-20 23:48:14 +03:30
|
|
|
|
</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 }}
|
|
|
|
|
|
>
|
2026-02-20 23:48:14 +03:30
|
|
|
|
<Box
|
2026-04-29 22:26:53 +03:30
|
|
|
|
className="bg-success rounded-full h-full transition-all"
|
2026-02-20 23:48:14 +03:30
|
|
|
|
sx={{ width: `${(plant.height / MAX_HEIGHT) * 100}%` }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Box>
|
2026-04-29 22:26:53 +03:30
|
|
|
|
<Typography
|
|
|
|
|
|
variant="body2"
|
|
|
|
|
|
color="success.main"
|
|
|
|
|
|
fontWeight="bold"
|
|
|
|
|
|
>
|
2026-02-20 23:48:14 +03:30
|
|
|
|
{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")}
|
2026-02-20 23:48:14 +03:30
|
|
|
|
</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 }}
|
|
|
|
|
|
>
|
2026-02-20 23:48:14 +03:30
|
|
|
|
<Box
|
2026-04-29 22:26:53 +03:30
|
|
|
|
className="bg-warning rounded-full h-full transition-all"
|
2026-02-20 23:48:14 +03:30
|
|
|
|
sx={{ width: `${env.light}%` }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Box>
|
2026-04-29 22:26:53 +03:30
|
|
|
|
<Typography
|
|
|
|
|
|
variant="body2"
|
|
|
|
|
|
color="warning.main"
|
|
|
|
|
|
fontWeight="bold"
|
|
|
|
|
|
>
|
2026-02-20 23:48:14 +03:30
|
|
|
|
{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")}
|
2026-02-20 23:48:14 +03:30
|
|
|
|
</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 }}
|
|
|
|
|
|
>
|
2026-02-20 23:48:14 +03:30
|
|
|
|
<Box
|
2026-04-29 22:26:53 +03:30
|
|
|
|
className="bg-info rounded-full h-full transition-all"
|
2026-02-20 23:48:14 +03:30
|
|
|
|
sx={{ width: `${env.water}%` }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Box>
|
2026-04-29 22:26:53 +03:30
|
|
|
|
<Typography
|
|
|
|
|
|
variant="body2"
|
|
|
|
|
|
color="info.main"
|
|
|
|
|
|
fontWeight="bold"
|
|
|
|
|
|
>
|
2026-02-20 23:48:14 +03:30
|
|
|
|
{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")}
|
2026-02-20 23:48:14 +03:30
|
|
|
|
</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 }}
|
|
|
|
|
|
>
|
2026-02-20 23:48:14 +03:30
|
|
|
|
<Box
|
2026-04-29 22:26:53 +03:30
|
|
|
|
className="bg-warning rounded-full h-full transition-all"
|
2026-02-20 23:48:14 +03:30
|
|
|
|
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"
|
|
|
|
|
|
>
|
2026-02-20 23:48:14 +03:30
|
|
|
|
{plant.yield.toFixed(1)}g
|
|
|
|
|
|
</Typography>
|
2026-04-29 22:26:53 +03:30
|
|
|
|
<Typography variant="caption" color="secondary.main">
|
2026-02-20 23:48:14 +03:30
|
|
|
|
{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>
|
2026-02-20 23:48:14 +03:30
|
|
|
|
</Typography>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</Grid>
|
|
|
|
|
|
</Grid>
|
|
|
|
|
|
</Box>
|
2026-04-29 22:26:53 +03:30
|
|
|
|
);
|
2026-02-20 23:08:44 +03:30
|
|
|
|
}
|