diff --git a/src/views/dashboards/farm/plantSimulator/PlantSimulator.tsx b/src/views/dashboards/farm/plantSimulator/PlantSimulator.tsx index ddae29e..70c7115 100644 --- a/src/views/dashboards/farm/plantSimulator/PlantSimulator.tsx +++ b/src/views/dashboards/farm/plantSimulator/PlantSimulator.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef, useState, useCallback } from 'react' +import { useEffect, useRef, useState, useCallback, memo } from 'react' import { Chart as ChartJS, LineElement, @@ -21,35 +21,62 @@ ChartJS.register(LineElement, PointElement, LinearScale, CategoryScale, Title, T interface Leaf { id: number side: 'left' | 'right' - heightFraction: number // 0..1 relative to stem - angle: number // base rotation angle in degrees - scale: number // 0..1 grow-in scale - swayOffset: number // random sway phase offset + 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 // current stem height px (0 → MAX_HEIGHT) + height: number leaves: Leaf[] + branches: Branch[] + fruits: Fruit[] tick: number - yield: number // accumulated yield in grams (0 → MAX_YIELD) - yieldRate: number // current yield production rate g/s + yield: number + yieldRate: number } interface EnvironmentSettings { - light: number // 0..100 - water: number // 0..100 + light: number + water: number } // ─── Constants ─────────────────────────────────────────────────────────────── -const MAX_HEIGHT = 260 -const MAX_YIELD = 500 // max yield in grams -const SVG_WIDTH = 200 -const SVG_HEIGHT = 320 -const STEM_X = SVG_WIDTH / 2 -const BASE_Y = SVG_HEIGHT - 10 -const LEAF_INTERVAL_PX = 36 // stem height growth between new leaves -const MAX_LEAVES = 12 +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 ──────────────── @@ -69,74 +96,357 @@ function computeYieldRate(env: EnvironmentSettings, leafCount: number, heightPro 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 }: { plant: PlantState; tick: number }) { +function PlantSVG({ plant, tick, running }: { plant: PlantState; tick: number; running: boolean }) { const stemTop = BASE_Y - plant.height - const stemHeight = 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 ( - - {/* Soil */} - + + + + + + + + + + + + + + + + + + + + + + + - {/* Stem */} - {stemHeight > 0 && ( - + {/* ── Floating pollen particles ── */} + {running && progress > 0.35 && [0, 1, 2, 3, 4, 5].map(i => { + const px = STEM_X + Math.sin(tick / 28 + i * 1.1) * 40 + const cycle = (tick * 0.35 + i * 30) % 80 + const py = stemTop - 12 - cycle + return + })} + + {/* ── Soil mound ── */} + + {[-24, -10, 4, 16, 28].map(dx => ( + + ))} + {/* Small grass tufts */} + {[-30, -14, 18, 32].map((dx, i) => ( + + ))} + + {/* ── Roots ── */} + {plant.height > 15 && ( + + {[ + { dx: -22, dy: 22, w: 2.2, cx: -10, cy: 8 }, + { dx: 18, dy: 20, w: 1.8, cx: 8, cy: 10 }, + { dx: -8, dy: 24, w: 1.2, cx: -4, cy: 12 }, + { dx: 12, dy: 18, w: 1, cx: 6, cy: 14 } + ].map((r, i) => ( + + ))} + )} - {/* Leaves */} - {plant.leaves.map(leaf => { - const leafY = BASE_Y - leaf.heightFraction * plant.height - const swayAngle = Math.sin((tick / 18 + leaf.swayOffset) * 1) * 4 - const baseAngle = leaf.side === 'left' ? -30 - swayAngle : 30 + swayAngle + {/* ── Main stem (quadratic bezier) ── */} + {plant.height > 0 && ( + <> + + {/* Stem highlight vein */} + + {/* Stem node bumps — small circles at branch attachment points */} + {plant.branches.map(b => { + const pt = stemPt(b.heightFraction) + return + })} + + )} + {/* ── Branches (curved) ── */} + {plant.branches.map(b => { + const { bx, by, tx, ty, cx, cy } = branchTip(b) + const bw = b.thickness * b.scale return ( - - + + + {/* branch highlight */} + + {/* small thorn/bud at tip */} + ) })} - {/* Bud / flower at the top */} - {stemHeight > MAX_HEIGHT * 0.85 && ( - - - - - )} + {/* ── Leaves ── */} + {plant.leaves.map(leaf => { + let ax: number, ay: number + if (leaf.branchId != null) { + const branch = plant.branches.find(b => b.id === leaf.branchId) + if (!branch) return null + const tip = branchTip(branch) + ax = tip.tx + ay = tip.ty + } else { + const pt = stemPt(leaf.heightFraction) + ax = pt.x + ay = pt.y + } + + const swayA = Math.sin(tick / 20 + leaf.swayOffset) * 6 + const dir = leaf.side === 'left' ? -1 : 1 + const rot = dir * 35 + swayA + const lx = leaf.length * leaf.scale + const ly = leaf.length * 0.4 * leaf.scale + const hue = 110 + leaf.id * 3 + const lit = 30 + leaf.id * 1.8 + const c1 = `hsl(${hue},65%,${lit}%)` + const c2 = `hsl(${hue},55%,${lit + 14}%)` + + return ( + + {/* Leaf shape — custom path for more natural look */} + + {/* Midrib */} + + {/* Side veins */} + {[0.25, 0.45, 0.65, 0.8].map((t, vi) => { + const vx = dir * lx * t * 0.95 + const vy = -ly * 0.12 * t + return ( + + ) + })} + + ) + })} + + {/* ── Fruits on branches and stem ── */} + {plant.fruits.map(fruit => { + const leaf = plant.leaves.find(l => l.id === fruit.leafId) + if (!leaf) return null + + let ox: number, oy: number + if (fruit.branchId != null) { + const branch = plant.branches.find(b => b.id === fruit.branchId) + if (!branch) return null + const tip = branchTip(branch) + ox = tip.tx + oy = tip.ty + } else { + const pt = stemPt(leaf.heightFraction) + ox = pt.x + oy = pt.y + } + + const fSway = Math.sin(tick / 25 + fruit.swayOffset) * 3 + const dir = fruit.side === 'left' ? -1 : 1 + const fr = fruit.size * fruit.scale + const fx = ox + dir * (12 + fr) + fSway + const fy = oy + 4 * fruit.scale + + return ( + + {/* Hanging stem */} + + {/* Fruit body */} + + {/* Depth gradient */} + + {/* Shine */} + + {/* Calyx (small leaves on top) */} + + + + ) + })} + + {/* ── Top bud → flower ── */} + {progress > 0.55 && (() => { + const bs = Math.min((progress - 0.55) / 0.3, 1) + const tx = s2x + const ty = s2y - 4 + const ps = Math.sin(tick / 32) * 5 + + return ( + + {/* Sepals (green base petals) */} + {[0, 72, 144, 216, 288].map(deg => { + const r = ((deg + ps * 0.5) * Math.PI) / 180 + return ( + + ) + })} + {/* Petals — larger, appear after 80% */} + {progress > 0.8 && [0, 45, 90, 135, 180, 225, 270, 315].map(deg => { + const petalScale = Math.min((progress - 0.8) / 0.15, 1) + const r = ((deg + ps) * Math.PI) / 180 + const pd = 10 * petalScale * bs + return ( + + ) + })} + {/* Centre */} + + + {/* Orbiting pollen */} + {progress > 0.88 && [0, 1, 2, 3, 4, 5].map(i => { + const a = (tick / 18 + i * 1.05) % (Math.PI * 2) + const or = 13 * bs + return + })} + + ) + })()} ) } // ─── Growth Chart ───────────────────────────────────────────────────────────── -function GrowthChart({ +const CHART_OPTIONS = { + responsive: true, + animation: { duration: 0 }, + plugins: { + legend: { labels: { color: '#e2e8f0', font: { size: 11 } } }, + title: { + display: true, + text: 'نمودار رشد گیاه', + color: '#e2e8f0', + font: { size: 14 } + } + }, + scales: { + x: { + ticks: { color: '#94a3b8', maxTicksLimit: 8 }, + grid: { color: 'rgba(148,163,184,0.1)' } + }, + yHeight: { + type: 'linear' as const, + position: 'left' as const, + min: 0, + max: MAX_HEIGHT, + ticks: { color: '#4a7c59' }, + grid: { color: 'rgba(148,163,184,0.1)' }, + title: { display: true, text: 'ارتفاع', color: '#4a7c59' } + }, + yLeaf: { + type: 'linear' as const, + position: 'right' as const, + min: 0, + max: MAX_LEAVES, + ticks: { color: '#f9c74f' }, + grid: { display: false }, + title: { display: true, text: 'برگ', color: '#f9c74f' } + }, + 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 GrowthChart = memo(function GrowthChart({ heightHistory, leafHistory, yieldHistory, @@ -196,70 +506,19 @@ function GrowthChart({ ] } - const options = { - responsive: true, - animation: { duration: 0 }, - plugins: { - legend: { labels: { color: '#e2e8f0', font: { size: 11 } } }, - title: { - display: true, - text: 'نمودار رشد گیاه', - color: '#e2e8f0', - font: { size: 14 } - } - }, - scales: { - x: { - ticks: { color: '#94a3b8', maxTicksLimit: 8 }, - grid: { color: 'rgba(148,163,184,0.1)' } - }, - yHeight: { - type: 'linear' as const, - position: 'left' as const, - min: 0, - max: MAX_HEIGHT, - ticks: { color: '#4a7c59' }, - grid: { color: 'rgba(148,163,184,0.1)' }, - title: { display: true, text: 'ارتفاع', color: '#4a7c59' } - }, - yLeaf: { - type: 'linear' as const, - position: 'right' as const, - min: 0, - max: MAX_LEAVES, - ticks: { color: '#f9c74f' }, - grid: { display: false }, - title: { display: true, text: 'برگ', color: '#f9c74f' } - }, - 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 } - } - } - } - - return -} + return +}) // ─── Main Component ─────────────────────────────────────────────────────────── +const INIT_PLANT: PlantState = { height: 0, leaves: [], branches: [], fruits: [], tick: 0, yield: 0, yieldRate: 0 } + export default function PlantSimulator() { const [running, setRunning] = useState(false) - const [speed, setSpeed] = useState(1.5) // px per tick base + const [speed, setSpeed] = useState(1.5) const [env, setEnv] = useState({ light: 75, water: 65 }) - const [plant, setPlant] = useState({ height: 0, leaves: [], tick: 0, yield: 0, yieldRate: 0 }) + const [plant, setPlant] = useState(INIT_PLANT) const [heightHistory, setHeightHistory] = useState([0]) const [leafHistory, setLeafHistory] = useState([0]) const [yieldHistory, setYieldHistory] = useState([0]) @@ -268,9 +527,11 @@ export default function PlantSimulator() { const intervalRef = useRef | null>(null) const tickRef = useRef(0) const historyIntervalRef = useRef | null>(null) + const plantRef = useRef(plant) + plantRef.current = plant const reset = useCallback(() => { - setPlant({ height: 0, leaves: [], tick: 0, yield: 0, yieldRate: 0 }) + setPlant(INIT_PLANT) setHeightHistory([0]) setLeafHistory([0]) setYieldHistory([0]) @@ -278,7 +539,12 @@ export default function PlantSimulator() { tickRef.current = 0 }, []) - // Main simulation tick at ~30fps + // 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) @@ -292,44 +558,127 @@ export default function PlantSimulator() { setPlant(prev => { const rate = growthRate(env, speed) const newHeight = Math.min(prev.height + rate * 0.4, MAX_HEIGHT) - const newLeaves = [...prev.leaves] + const hp = newHeight / MAX_HEIGHT - // Add a new leaf every LEAF_INTERVAL_PX of stem growth - const expectedLeaves = Math.min(Math.floor(newHeight / LEAF_INTERVAL_PX), MAX_LEAVES) - - while (newLeaves.length < expectedLeaves) { - const id = newLeaves.length - newLeaves.push({ - id, - side: id % 2 === 0 ? 'left' : 'right', - heightFraction: (id + 1) / (expectedLeaves + 1), - angle: 0, + // ── 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 + 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 }) } - // Grow existing leaves (scale 0 → 1 over ~40 ticks) - const grownLeaves = newLeaves.map(l => ({ - ...l, - scale: Math.min(l.scale + 0.025, 1) + const grownFruits = newFruits.map(f => ({ + ...f, + scale: Math.min(f.scale + 0.014, 1) })) - const heightProgress = newHeight / MAX_HEIGHT - const currentYieldRate = computeYieldRate(env, grownLeaves.length, heightProgress) - // accumulate yield each tick (33ms ≈ 0.033s) - const newYield = Math.min(prev.yield + currentYieldRate * 0.033, MAX_YIELD) - - return { height: newHeight, leaves: grownLeaves, tick: t, yield: newYield, yieldRate: currentYieldRate } + return { + height: newHeight, + leaves: grownLeaves, + branches: grownBranches, + fruits: grownFruits, + tick: t, + yield: newYield, + yieldRate: currentYieldRate + } }) - }, 33) + }, 50) return () => { if (intervalRef.current) clearInterval(intervalRef.current) } }, [running, speed, env]) - // History logging every 1 second + // History logging every 1 second (reads from plantRef to avoid setPlant side-effects) useEffect(() => { if (!running) { if (historyIntervalRef.current) clearInterval(historyIntervalRef.current) @@ -337,13 +686,11 @@ export default function PlantSimulator() { } historyIntervalRef.current = setInterval(() => { - setPlant(prev => { - setHeightHistory(h => [...h.slice(-59), Math.round(prev.height)]) - setLeafHistory(l => [...l.slice(-59), prev.leaves.length]) - setYieldHistory(y => [...y.slice(-59), parseFloat(prev.yield.toFixed(1))]) - setYieldRateHistory(r => [...r.slice(-59), prev.yieldRate]) - return prev - }) + 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 () => { @@ -364,25 +711,33 @@ export default function PlantSimulator() { {/* ── Left: Plant visualization ── */}
- + {/* Stats */} -
-
-
{Math.round(plant.height)}
-
ارتفاع (px)
+
+
+
{Math.round(plant.height)}
+
ارتفاع
-
-
{plant.leaves.length}
-
تعداد برگ
+
+
{plant.leaves.length}
+
برگ
-
-
{plant.yield.toFixed(1)}
-
محصول (g)
+
+
{plant.branches.length}
+
شاخه
-
-
{plant.yieldRate.toFixed(3)}
-
سرعت محصول (g/s)
+
+
{plant.fruits.length}
+
میوه
+
+
+
{plant.yield.toFixed(1)}
+
محصول (g)
+
+
+
{plant.yieldRate.toFixed(2)}
+
سرعت (g/s)