Add weather information section and update dependencies
- Introduced a new CropZoningWeatherSection component to display weather data in the crop zoning dashboard. - Updated package.json and package-lock.json to include chart.js and react-chartjs-2 for enhanced data visualization. - Added Persian translations for weather-related UI elements to support localization.
This commit is contained in:
@@ -552,6 +552,14 @@
|
|||||||
"reason": "دلیل پیشنهاد",
|
"reason": "دلیل پیشنهاد",
|
||||||
"criteriaChart": "نمودار تطابق معیارها",
|
"criteriaChart": "نمودار تطابق معیارها",
|
||||||
"changeCrop": "تغییر محصول"
|
"changeCrop": "تغییر محصول"
|
||||||
|
},
|
||||||
|
"weather": {
|
||||||
|
"title": "اطلاعات هواشناسی",
|
||||||
|
"temperature": "دما",
|
||||||
|
"humidity": "رطوبت",
|
||||||
|
"windSpeed": "سرعت باد",
|
||||||
|
"forecastChart": "پیشبینی هوا",
|
||||||
|
"forecastSubheader": "دما و رطوبت در ۷ روز آینده"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+30
@@ -51,6 +51,7 @@
|
|||||||
"@types/leaflet-draw": "^1.0.13",
|
"@types/leaflet-draw": "^1.0.13",
|
||||||
"apexcharts": "3.49.0",
|
"apexcharts": "3.49.0",
|
||||||
"bootstrap-icons": "1.11.3",
|
"bootstrap-icons": "1.11.3",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"classnames": "2.5.1",
|
"classnames": "2.5.1",
|
||||||
"cmdk": "1.0.4",
|
"cmdk": "1.0.4",
|
||||||
"date-fns": "4.1.0",
|
"date-fns": "4.1.0",
|
||||||
@@ -66,6 +67,7 @@
|
|||||||
"next-intl": "3.25.2",
|
"next-intl": "3.25.2",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-apexcharts": "1.4.1",
|
"react-apexcharts": "1.4.1",
|
||||||
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-colorful": "5.6.1",
|
"react-colorful": "5.6.1",
|
||||||
"react-date-object": "1.1.9",
|
"react-date-object": "1.1.9",
|
||||||
"react-datepicker": "7.3.0",
|
"react-datepicker": "7.3.0",
|
||||||
@@ -1707,6 +1709,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@kurkle/color": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://mirror-npm.runflare.com/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@mapbox/geojson-area": {
|
"node_modules/@mapbox/geojson-area": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://mirror-npm.runflare.com/@mapbox/geojson-area/-/geojson-area-0.2.2.tgz",
|
"resolved": "https://mirror-npm.runflare.com/@mapbox/geojson-area/-/geojson-area-0.2.2.tgz",
|
||||||
@@ -4566,6 +4574,18 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chart.js": {
|
||||||
|
"version": "4.5.1",
|
||||||
|
"resolved": "https://mirror-npm.runflare.com/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
|
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@kurkle/color": "^0.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"pnpm": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cheap-ruler": {
|
"node_modules/cheap-ruler": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
@@ -8952,6 +8972,16 @@
|
|||||||
"react": ">=0.13"
|
"react": ">=0.13"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-chartjs-2": {
|
||||||
|
"version": "5.3.1",
|
||||||
|
"resolved": "https://mirror-npm.runflare.com/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz",
|
||||||
|
"integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"chart.js": "^4.1.1",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-colorful": {
|
"node_modules/react-colorful": {
|
||||||
"version": "5.6.1",
|
"version": "5.6.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
"@types/leaflet-draw": "^1.0.13",
|
"@types/leaflet-draw": "^1.0.13",
|
||||||
"apexcharts": "3.49.0",
|
"apexcharts": "3.49.0",
|
||||||
"bootstrap-icons": "1.11.3",
|
"bootstrap-icons": "1.11.3",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"classnames": "2.5.1",
|
"classnames": "2.5.1",
|
||||||
"cmdk": "1.0.4",
|
"cmdk": "1.0.4",
|
||||||
"date-fns": "4.1.0",
|
"date-fns": "4.1.0",
|
||||||
@@ -71,6 +72,7 @@
|
|||||||
"next-intl": "3.25.2",
|
"next-intl": "3.25.2",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-apexcharts": "1.4.1",
|
"react-apexcharts": "1.4.1",
|
||||||
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-colorful": "5.6.1",
|
"react-colorful": "5.6.1",
|
||||||
"react-date-object": "1.1.9",
|
"react-date-object": "1.1.9",
|
||||||
"react-datepicker": "7.3.0",
|
"react-datepicker": "7.3.0",
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import PlantSimulator from '@views/dashboards/farm/plantSimulator/PlantSimulator'
|
||||||
|
|
||||||
|
const PlantSimulatorPage = () => {
|
||||||
|
return <PlantSimulator />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PlantSimulatorPage
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
|
||||||
|
// MUI Imports
|
||||||
|
import Box from '@mui/material/Box'
|
||||||
|
import Grid from '@mui/material/Grid2'
|
||||||
|
import Card from '@mui/material/Card'
|
||||||
|
import CardHeader from '@mui/material/CardHeader'
|
||||||
|
import CardContent from '@mui/material/CardContent'
|
||||||
|
import Typography from '@mui/material/Typography'
|
||||||
|
|
||||||
|
// Third-party Imports
|
||||||
|
import type { ApexOptions } from 'apexcharts'
|
||||||
|
|
||||||
|
// Component Imports
|
||||||
|
import FarmWeatherCard from '@views/dashboards/farm/FarmWeatherCard'
|
||||||
|
import { farmDashboardService } from '@/libs/api/services/farmDashboardService'
|
||||||
|
|
||||||
|
// Styled Component Imports
|
||||||
|
const AppReactApexCharts = dynamic(() => import('@/libs/styles/AppReactApexCharts'))
|
||||||
|
|
||||||
|
const DEFAULT_WEATHER = {
|
||||||
|
temperature: 24,
|
||||||
|
condition: 'آفتابی',
|
||||||
|
humidity: 45,
|
||||||
|
windSpeed: 12,
|
||||||
|
windUnit: 'km/h',
|
||||||
|
unit: '°C',
|
||||||
|
precipitation: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FORECAST_SERIES = [
|
||||||
|
{ name: 'دما', data: [18, 22, 26, 28, 25, 20, 18] },
|
||||||
|
{ name: 'رطوبت', data: [55, 48, 42, 38, 45, 52, 58] }
|
||||||
|
]
|
||||||
|
|
||||||
|
const FORECAST_CATEGORIES = ['امروز', 'فردا', 'شنبه', 'یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه']
|
||||||
|
|
||||||
|
export default function CropZoningWeatherSection() {
|
||||||
|
const t = useTranslations('cropZoning.weather')
|
||||||
|
const [weatherData, setWeatherData] = useState<Record<string, unknown>>(DEFAULT_WEATHER)
|
||||||
|
const [forecastSeries, setForecastSeries] = useState(DEFAULT_FORECAST_SERIES)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
farmDashboardService
|
||||||
|
.getAllCards()
|
||||||
|
.then(cards => {
|
||||||
|
const w = cards?.farmWeatherCard
|
||||||
|
if (w && typeof w === 'object') {
|
||||||
|
setWeatherData({ ...DEFAULT_WEATHER, ...w })
|
||||||
|
const chartData = w.chartData as { labels?: string[]; series?: number[][] } | undefined
|
||||||
|
if (chartData?.series?.[0]) {
|
||||||
|
setForecastSeries([{ name: 'دما', data: chartData.series[0] }])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const forecastOptions: ApexOptions = {
|
||||||
|
chart: {
|
||||||
|
parentHeightOffset: 0,
|
||||||
|
toolbar: { show: false },
|
||||||
|
zoom: { enabled: false }
|
||||||
|
},
|
||||||
|
colors: ['var(--mui-palette-info-main)', 'var(--mui-palette-success-main)'],
|
||||||
|
stroke: { width: 2, curve: 'smooth' },
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
labels: { colors: 'var(--mui-palette-text-secondary)' }
|
||||||
|
},
|
||||||
|
dataLabels: { enabled: false },
|
||||||
|
grid: {
|
||||||
|
borderColor: 'var(--mui-palette-divider)',
|
||||||
|
strokeDashArray: 4,
|
||||||
|
xaxis: { lines: { show: false } },
|
||||||
|
yaxis: { lines: { show: true } }
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: FORECAST_CATEGORIES,
|
||||||
|
labels: { style: { colors: 'var(--mui-palette-text-disabled)' } },
|
||||||
|
axisBorder: { show: false },
|
||||||
|
axisTicks: { show: false }
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
labels: {
|
||||||
|
style: { colors: 'var(--mui-palette-text-disabled)' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const temp = (weatherData.temperature as number) ?? 24
|
||||||
|
const humidity = (weatherData.humidity as number) ?? 45
|
||||||
|
const windSpeed = (weatherData.windSpeed as number) ?? 12
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className='pis-0 pie-0'>
|
||||||
|
<Typography variant='h6' className='mbe-4 font-semibold'>
|
||||||
|
{t('title')}
|
||||||
|
</Typography>
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid size={{ xs: 12, md: 6, lg: 4 }}>
|
||||||
|
<FarmWeatherCard data={weatherData} />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 2 }}>
|
||||||
|
<Card>
|
||||||
|
<CardContent className='flex flex-col items-center gap-2'>
|
||||||
|
<i className='tabler-temperature text-3xl text-error' />
|
||||||
|
<Typography variant='body2' color='text.secondary'>
|
||||||
|
{t('temperature')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant='h5'>
|
||||||
|
{temp}
|
||||||
|
{weatherData.unit ?? '°C'}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 2 }}>
|
||||||
|
<Card>
|
||||||
|
<CardContent className='flex flex-col items-center gap-2'>
|
||||||
|
<i className='tabler-droplet text-3xl text-info' />
|
||||||
|
<Typography variant='body2' color='text.secondary'>
|
||||||
|
{t('humidity')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant='h5'>{humidity}%</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 2 }}>
|
||||||
|
<Card>
|
||||||
|
<CardContent className='flex flex-col items-center gap-2'>
|
||||||
|
<i className='tabler-wind text-3xl text-success' />
|
||||||
|
<Typography variant='body2' color='text.secondary'>
|
||||||
|
{t('windSpeed')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant='h5'>
|
||||||
|
{windSpeed} {(weatherData.windUnit as string) ?? 'km/h'}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title={t('forecastChart')}
|
||||||
|
subheader={t('forecastSubheader')}
|
||||||
|
/>
|
||||||
|
<CardContent>
|
||||||
|
<AppReactApexCharts
|
||||||
|
type='line'
|
||||||
|
height={260}
|
||||||
|
width='100%'
|
||||||
|
series={forecastSeries}
|
||||||
|
options={forecastOptions}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import CropZoningMap from './CropZoningMap'
|
|||||||
import ZoneLegend from './ZoneLegend'
|
import ZoneLegend from './ZoneLegend'
|
||||||
import LayerControl from './LayerControl'
|
import LayerControl from './LayerControl'
|
||||||
import ZoneDetailPanel from './ZoneDetailPanel'
|
import ZoneDetailPanel from './ZoneDetailPanel'
|
||||||
|
import CropZoningWeatherSection from './CropZoningWeatherSection'
|
||||||
import type { LayerType } from './cropZoningTypes'
|
import type { LayerType } from './cropZoningTypes'
|
||||||
import type { ZoneFeatureProperties } from './cropZoningTypes'
|
import type { ZoneFeatureProperties } from './cropZoningTypes'
|
||||||
import type { MapDrawGeoJSON } from './CropZoningMap'
|
import type { MapDrawGeoJSON } from './CropZoningMap'
|
||||||
@@ -45,7 +46,8 @@ export default function CropZoningWrapper() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className='relative is-full min-bs-[calc(100vh-120px)] rounded-xl overflow-hidden'>
|
<Box className='flex flex-col gap-6 is-full'>
|
||||||
|
<Box className='relative min-bs-[400px] rounded-xl overflow-hidden' sx={{ height: 'min(60vh, 500px)' }}>
|
||||||
<Box className='absolute inset-0 z-0'>
|
<Box className='absolute inset-0 z-0'>
|
||||||
<MapComponent
|
<MapComponent
|
||||||
center={[35.6892, 51.389]}
|
center={[35.6892, 51.389]}
|
||||||
@@ -85,5 +87,8 @@ export default function CropZoningWrapper() {
|
|||||||
zone={selectedZone}
|
zone={selectedZone}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<CropZoningWeatherSection />
|
||||||
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export { default as CropZoningWrapper } from './CropZoningWrapper'
|
export { default as CropZoningWrapper } from './CropZoningWrapper'
|
||||||
|
export { default as CropZoningWeatherSection } from './CropZoningWeatherSection'
|
||||||
export { default as CropZoningMap } from './CropZoningMap'
|
export { default as CropZoningMap } from './CropZoningMap'
|
||||||
export { default as ZoneLegend } from './ZoneLegend'
|
export { default as ZoneLegend } from './ZoneLegend'
|
||||||
export { default as LayerControl } from './LayerControl'
|
export { default as LayerControl } from './LayerControl'
|
||||||
|
|||||||
@@ -0,0 +1,457 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
LinearScale,
|
||||||
|
CategoryScale,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler
|
||||||
|
} from 'chart.js'
|
||||||
|
import { Line } from 'react-chartjs-2'
|
||||||
|
|
||||||
|
ChartJS.register(LineElement, PointElement, LinearScale, CategoryScale, Title, Tooltip, Legend, Filler)
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlantState {
|
||||||
|
height: number // current stem height px (0 → MAX_HEIGHT)
|
||||||
|
leaves: Leaf[]
|
||||||
|
tick: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EnvironmentSettings {
|
||||||
|
light: number // 0..100
|
||||||
|
water: number // 0..100
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MAX_HEIGHT = 260
|
||||||
|
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
|
||||||
|
|
||||||
|
// ─── Helper: compute growth speed multiplier from env settings ────────────────
|
||||||
|
|
||||||
|
function growthRate(env: EnvironmentSettings, speed: number): number {
|
||||||
|
const lightFactor = 0.3 + (env.light / 100) * 0.7
|
||||||
|
const waterFactor = 0.3 + (env.water / 100) * 0.7
|
||||||
|
return speed * lightFactor * waterFactor
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Plant SVG Component ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function PlantSVG({ plant, tick }: { plant: PlantState; tick: number }) {
|
||||||
|
const stemTop = BASE_Y - plant.height
|
||||||
|
const stemHeight = plant.height
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={SVG_WIDTH}
|
||||||
|
height={SVG_HEIGHT}
|
||||||
|
viewBox={`0 0 ${SVG_WIDTH} ${SVG_HEIGHT}`}
|
||||||
|
className='drop-shadow-lg'
|
||||||
|
>
|
||||||
|
{/* Soil */}
|
||||||
|
<ellipse cx={STEM_X} cy={BASE_Y + 4} rx={40} ry={8} fill='#8B6914' opacity={0.6} />
|
||||||
|
|
||||||
|
{/* Stem */}
|
||||||
|
{stemHeight > 0 && (
|
||||||
|
<rect
|
||||||
|
x={STEM_X - 4}
|
||||||
|
y={stemTop}
|
||||||
|
width={8}
|
||||||
|
height={stemHeight}
|
||||||
|
rx={4}
|
||||||
|
fill='#4a7c59'
|
||||||
|
className='transition-all duration-300'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={leaf.id}
|
||||||
|
transform={`translate(${STEM_X}, ${leafY})`}
|
||||||
|
style={{ transformOrigin: `${STEM_X}px ${leafY}px` }}
|
||||||
|
>
|
||||||
|
<ellipse
|
||||||
|
cx={leaf.side === 'left' ? -22 * leaf.scale : 22 * leaf.scale}
|
||||||
|
cy={-6 * leaf.scale}
|
||||||
|
rx={22 * leaf.scale}
|
||||||
|
ry={10 * leaf.scale}
|
||||||
|
fill={`hsl(${115 + leaf.id * 3}, 60%, ${38 + leaf.id * 2}%)`}
|
||||||
|
transform={`rotate(${baseAngle})`}
|
||||||
|
opacity={leaf.scale}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Bud / flower at the top */}
|
||||||
|
{stemHeight > MAX_HEIGHT * 0.85 && (
|
||||||
|
<g>
|
||||||
|
<circle cx={STEM_X} cy={stemTop - 8} r={8} fill='#f9c74f' opacity={0.9} />
|
||||||
|
<circle cx={STEM_X} cy={stemTop - 8} r={4} fill='#f3722c' />
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Growth Chart ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function GrowthChart({
|
||||||
|
heightHistory,
|
||||||
|
leafHistory
|
||||||
|
}: {
|
||||||
|
heightHistory: number[]
|
||||||
|
leafHistory: number[]
|
||||||
|
}) {
|
||||||
|
const labels = heightHistory.map((_, i) => `${i}s`)
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'ارتفاع (px)',
|
||||||
|
data: heightHistory,
|
||||||
|
borderColor: '#4a7c59',
|
||||||
|
backgroundColor: 'rgba(74,124,89,0.15)',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.4,
|
||||||
|
pointRadius: 0,
|
||||||
|
yAxisID: 'yHeight'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'تعداد برگ',
|
||||||
|
data: leafHistory,
|
||||||
|
borderColor: '#f9c74f',
|
||||||
|
backgroundColor: 'rgba(249,199,79,0.15)',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.4,
|
||||||
|
pointRadius: 0,
|
||||||
|
yAxisID: 'yLeaf'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
responsive: true,
|
||||||
|
animation: { duration: 0 },
|
||||||
|
plugins: {
|
||||||
|
legend: { labels: { color: '#e2e8f0', font: { size: 12 } } },
|
||||||
|
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' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Line data={data} options={options} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function PlantSimulator() {
|
||||||
|
const [running, setRunning] = useState(false)
|
||||||
|
const [speed, setSpeed] = useState(1.5) // px per tick base
|
||||||
|
const [env, setEnv] = useState<EnvironmentSettings>({ light: 75, water: 65 })
|
||||||
|
|
||||||
|
const [plant, setPlant] = useState<PlantState>({ height: 0, leaves: [], tick: 0 })
|
||||||
|
const [heightHistory, setHeightHistory] = useState<number[]>([0])
|
||||||
|
const [leafHistory, setLeafHistory] = useState<number[]>([0])
|
||||||
|
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
const tickRef = useRef(0)
|
||||||
|
const historyIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setPlant({ height: 0, leaves: [], tick: 0 })
|
||||||
|
setHeightHistory([0])
|
||||||
|
setLeafHistory([0])
|
||||||
|
tickRef.current = 0
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Main simulation tick at ~30fps
|
||||||
|
useEffect(() => {
|
||||||
|
if (!running) {
|
||||||
|
if (intervalRef.current) clearInterval(intervalRef.current)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
tickRef.current += 1
|
||||||
|
const t = tickRef.current
|
||||||
|
|
||||||
|
setPlant(prev => {
|
||||||
|
const rate = growthRate(env, speed)
|
||||||
|
const newHeight = Math.min(prev.height + rate * 0.4, MAX_HEIGHT)
|
||||||
|
const newLeaves = [...prev.leaves]
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
scale: 0,
|
||||||
|
swayOffset: Math.random() * Math.PI * 2
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grow existing leaves (scale 0 → 1 over ~40 ticks)
|
||||||
|
const grownLeaves = newLeaves.map(l => ({
|
||||||
|
...l,
|
||||||
|
scale: Math.min(l.scale + 0.025, 1)
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { height: newHeight, leaves: grownLeaves, tick: t }
|
||||||
|
})
|
||||||
|
}, 33)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) clearInterval(intervalRef.current)
|
||||||
|
}
|
||||||
|
}, [running, speed, env])
|
||||||
|
|
||||||
|
// History logging every 1 second
|
||||||
|
useEffect(() => {
|
||||||
|
if (!running) {
|
||||||
|
if (historyIntervalRef.current) clearInterval(historyIntervalRef.current)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
historyIntervalRef.current = setInterval(() => {
|
||||||
|
setPlant(prev => {
|
||||||
|
setHeightHistory(h => [...h.slice(-59), Math.round(prev.height)])
|
||||||
|
setLeafHistory(l => [...l.slice(-59), prev.leaves.length])
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (historyIntervalRef.current) clearInterval(historyIntervalRef.current)
|
||||||
|
}
|
||||||
|
}, [running])
|
||||||
|
|
||||||
|
const isFinished = plant.height >= MAX_HEIGHT
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-slate-100 p-6'>
|
||||||
|
<h1 className='text-3xl font-bold text-center mb-8 tracking-tight'>
|
||||||
|
🌱 شبیهساز رشد گیاه
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className='max-w-6xl mx-auto grid grid-cols-1 lg:grid-cols-3 gap-6'>
|
||||||
|
|
||||||
|
{/* ── Left: Plant visualization ── */}
|
||||||
|
<div className='lg:col-span-1 flex flex-col items-center gap-4'>
|
||||||
|
<div className='bg-slate-800 border border-slate-700 rounded-2xl p-6 w-full flex flex-col items-center shadow-xl'>
|
||||||
|
<PlantSVG plant={plant} tick={plant.tick} />
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className='mt-4 grid grid-cols-2 gap-3 w-full text-sm'>
|
||||||
|
<div className='bg-green-900/40 border border-green-700/40 rounded-xl p-3 text-center'>
|
||||||
|
<div className='text-green-400 font-semibold text-lg'>{Math.round(plant.height)}</div>
|
||||||
|
<div className='text-slate-400'>ارتفاع (px)</div>
|
||||||
|
</div>
|
||||||
|
<div className='bg-yellow-900/40 border border-yellow-700/40 rounded-xl p-3 text-center'>
|
||||||
|
<div className='text-yellow-400 font-semibold text-lg'>{plant.leaves.length}</div>
|
||||||
|
<div className='text-slate-400'>تعداد برگ</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isFinished && (
|
||||||
|
<div className='mt-3 text-yellow-300 text-sm font-medium animate-pulse'>
|
||||||
|
🌼 گیاه به حداکثر رشد رسید!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className='bg-slate-800 border border-slate-700 rounded-2xl p-5 w-full shadow-xl space-y-4'>
|
||||||
|
<h2 className='font-semibold text-slate-300 text-base mb-1'>کنترلها</h2>
|
||||||
|
|
||||||
|
{/* Start / Stop / Reset */}
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<button
|
||||||
|
onClick={() => setRunning(r => !r)}
|
||||||
|
disabled={isFinished}
|
||||||
|
className={`flex-1 py-2 rounded-xl font-semibold transition-all text-sm
|
||||||
|
${running
|
||||||
|
? 'bg-red-600 hover:bg-red-500'
|
||||||
|
: 'bg-green-600 hover:bg-green-500'}
|
||||||
|
disabled:opacity-40 disabled:cursor-not-allowed`}
|
||||||
|
>
|
||||||
|
{running ? '⏸ توقف' : '▶ شروع'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setRunning(false); reset() }}
|
||||||
|
className='px-4 py-2 rounded-xl bg-slate-600 hover:bg-slate-500 text-sm font-semibold transition-all'
|
||||||
|
>
|
||||||
|
↺ ریست
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Speed slider */}
|
||||||
|
<div>
|
||||||
|
<label className='flex justify-between text-sm text-slate-400 mb-1'>
|
||||||
|
<span>سرعت رشد</span>
|
||||||
|
<span className='text-white font-medium'>{speed.toFixed(1)}×</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='range' min={0.5} max={5} step={0.5}
|
||||||
|
value={speed}
|
||||||
|
onChange={e => setSpeed(Number(e.target.value))}
|
||||||
|
className='w-full accent-green-500'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Light */}
|
||||||
|
<div>
|
||||||
|
<label className='flex justify-between text-sm text-slate-400 mb-1'>
|
||||||
|
<span>☀️ نور</span>
|
||||||
|
<span className='text-yellow-400 font-medium'>{env.light}%</span>
|
||||||
|
</label>
|
||||||
|
<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 accent-yellow-400'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Water */}
|
||||||
|
<div>
|
||||||
|
<label className='flex justify-between text-sm text-slate-400 mb-1'>
|
||||||
|
<span>💧 آب</span>
|
||||||
|
<span className='text-blue-400 font-medium'>{env.water}%</span>
|
||||||
|
</label>
|
||||||
|
<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 accent-blue-400'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Effective rate indicator */}
|
||||||
|
<div className='bg-slate-700/50 rounded-xl p-3 text-xs text-slate-400'>
|
||||||
|
نرخ رشد مؤثر:{' '}
|
||||||
|
<span className='text-green-400 font-semibold'>
|
||||||
|
{growthRate(env, speed).toFixed(2)}×
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Right: Chart ── */}
|
||||||
|
<div className='lg:col-span-2 bg-slate-800 border border-slate-700 rounded-2xl p-6 shadow-xl'>
|
||||||
|
<GrowthChart heightHistory={heightHistory} leafHistory={leafHistory} />
|
||||||
|
|
||||||
|
{/* Info cards */}
|
||||||
|
<div className='mt-6 grid grid-cols-3 gap-4 text-sm'>
|
||||||
|
<div className='bg-slate-700/60 rounded-xl p-4 border border-slate-600'>
|
||||||
|
<div className='text-slate-400 mb-1'>پیشرفت رشد</div>
|
||||||
|
<div className='w-full bg-slate-600 rounded-full h-2 mb-1'>
|
||||||
|
<div
|
||||||
|
className='bg-green-500 h-2 rounded-full transition-all'
|
||||||
|
style={{ width: `${(plant.height / MAX_HEIGHT) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='text-green-400 font-semibold'>
|
||||||
|
{Math.round((plant.height / MAX_HEIGHT) * 100)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='bg-slate-700/60 rounded-xl p-4 border border-slate-600'>
|
||||||
|
<div className='text-slate-400 mb-1'>وضعیت نور</div>
|
||||||
|
<div className='w-full bg-slate-600 rounded-full h-2 mb-1'>
|
||||||
|
<div
|
||||||
|
className='bg-yellow-400 h-2 rounded-full transition-all'
|
||||||
|
style={{ width: `${env.light}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='text-yellow-400 font-semibold'>{env.light}%</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='bg-slate-700/60 rounded-xl p-4 border border-slate-600'>
|
||||||
|
<div className='text-slate-400 mb-1'>وضعیت آب</div>
|
||||||
|
<div className='w-full bg-slate-600 rounded-full h-2 mb-1'>
|
||||||
|
<div
|
||||||
|
className='bg-blue-400 h-2 rounded-full transition-all'
|
||||||
|
style={{ width: `${env.water}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='text-blue-400 font-semibold'>{env.water}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className='mt-6 bg-slate-700/30 border border-slate-600/50 rounded-xl p-4 text-xs text-slate-400 leading-6'>
|
||||||
|
<p>
|
||||||
|
این شبیهساز رشد گیاه را بر اساس سرعت پایه، میزان نور خورشید و آب دریافتی
|
||||||
|
محاسبه میکند. هر برگ به صورت تدریجی روی ساقه ظاهر شده و با حرکت طبیعی
|
||||||
|
در باد نمایش داده میشود. نمودار تغییرات ارتفاع و تعداد برگها را در طول
|
||||||
|
زمان ثبت میکند.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user