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:
2026-02-20 23:08:44 +03:30
parent 02e966e997
commit bb83ab506e
8 changed files with 692 additions and 12 deletions
@@ -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 LayerControl from './LayerControl'
import ZoneDetailPanel from './ZoneDetailPanel'
import CropZoningWeatherSection from './CropZoningWeatherSection'
import type { LayerType } from './cropZoningTypes'
import type { ZoneFeatureProperties } from './cropZoningTypes'
import type { MapDrawGeoJSON } from './CropZoningMap'
@@ -45,18 +46,19 @@ export default function CropZoningWrapper() {
}, [])
return (
<Box className='relative is-full min-bs-[calc(100vh-120px)] rounded-xl overflow-hidden'>
<Box className='absolute inset-0 z-0'>
<MapComponent
center={[35.6892, 51.389]}
zoom={13}
height='100%'
activeLayer={activeLayer}
onAreaChange={handleAreaChange}
onZoneClick={handleZoneClick}
optimizationKey={optimizationKey}
className='min-bs-[400px]'
/>
<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'>
<MapComponent
center={[35.6892, 51.389]}
zoom={13}
height='100%'
activeLayer={activeLayer}
onAreaChange={handleAreaChange}
onZoneClick={handleZoneClick}
optimizationKey={optimizationKey}
className='min-bs-[400px]'
/>
</Box>
<LayerControl activeLayer={activeLayer} onLayerChange={setActiveLayer} />
@@ -84,6 +86,9 @@ export default function CropZoningWrapper() {
onClose={() => setPanelOpen(false)}
zone={selectedZone}
/>
</Box>
<CropZoningWeatherSection />
</Box>
)
}
@@ -1,4 +1,5 @@
export { default as CropZoningWrapper } from './CropZoningWrapper'
export { default as CropZoningWeatherSection } from './CropZoningWeatherSection'
export { default as CropZoningMap } from './CropZoningMap'
export { default as ZoneLegend } from './ZoneLegend'
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>
)
}