From 5aea10a75674d18f6acf0d38864003996beeb7ca Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Thu, 26 Feb 2026 00:37:00 +0330 Subject: [PATCH] Enhance crop zoning features with new API integrations and UI updates - Added Persian translations for legend levels in fa.json to improve localization. - Updated CROP_ZONING_APIS.md to include new API endpoints for water need, soil quality, and cultivation risk, enhancing data retrieval capabilities. - Refactored crop zoning components to support new data structures and improve rendering logic for different layers. - Enhanced LayerControl and ZoneLegend components to dynamically display information based on the active layer, improving user experience. - Implemented loading states and error handling in CropZoningWrapper for better data management during asynchronous operations. --- messages/fa.json | 5 + .../(private)/crop-zoning/CROP_ZONING_APIS.md | 184 ++++++++++++++++-- src/libs/api/services/cropZoningService.ts | 71 ++++++- .../farm/cropZoning/CropZoningMap.tsx | 86 +++----- .../farm/cropZoning/CropZoningWrapper.tsx | 138 ++++++++++++- .../farm/cropZoning/LayerControl.tsx | 41 ++-- .../dashboards/farm/cropZoning/ZoneLegend.tsx | 68 ++++++- 7 files changed, 484 insertions(+), 109 deletions(-) diff --git a/messages/fa.json b/messages/fa.json index faec7af..098adbc 100644 --- a/messages/fa.json +++ b/messages/fa.json @@ -544,6 +544,11 @@ "cultivationRisk": "ریسک کشت" }, "legend": "راهنمای رنگ‌ها", + "legendLevels": { + "low": "کم", + "medium": "متوسط", + "high": "زیاد" + }, "crops": { "wheat": "گندم", "canola": "کلزا", diff --git a/src/app/(dashboard)/(private)/crop-zoning/CROP_ZONING_APIS.md b/src/app/(dashboard)/(private)/crop-zoning/CROP_ZONING_APIS.md index d1a7a1f..63b994e 100644 --- a/src/app/(dashboard)/(private)/crop-zoning/CROP_ZONING_APIS.md +++ b/src/app/(dashboard)/(private)/crop-zoning/CROP_ZONING_APIS.md @@ -10,18 +10,24 @@ ## نمای کلی و جریان درخواست‌ها ``` -۱. GET area → منطقهٔ ثابت (کاربر امکان رسم ندارد) -۲. GET products → لیست محصولات و رنگ‌ها -۳. POST zones/initial → ارسال محدودهٔ مربع‌ها → دریافت دیتای اولیه (نقشه + هاور/tooltip) -۴. GET zone/:zoneId → کلیک روی مربع → دریافت دیتای تکمیلی (پنل جزئیات: reason, criteria, ...) +۱. GET area → منطقهٔ ثابت (کاربر امکان رسم ندارد) +۲. GET products → لیست محصولات و رنگ‌ها +۳. POST zones/initial → ارسال محدودهٔ مربع‌ها → دیتای محصولات پیشنهادی (نقشه + tooltip) +۴. POST zones/water-need → ارسال محدودهٔ مربع‌ها → نیاز آبی هر منطقه +۵. POST zones/soil-quality → ارسال محدودهٔ مربع‌ها → کیفیت خاک هر منطقه +۶. POST zones/cultivation-risk → ارسال محدودهٔ مربع‌ها → ریسک کشت هر منطقه +۷. GET zone/:zoneId → کلیک روی مربع → دیتای تکمیلی (پنل جزئیات: reason, criteria, ...) ``` | ردیف | API | هدف | |------|-----|------| | ۱ | **منطقهٔ اولیه** | دریافت منطقهٔ زمین به صورت GeoJSON؛ کاربر نمی‌تواند چیزی رسم کند | | ۲ | **لیست محصولات و رنگ‌ها** | دریافت محصولات قابل کشت به همراه رنگ نمایش و لیبل فارسی | -| ۳ | **دیتای اولیه زون‌ها** | ارسال محدودهٔ مربع‌ها، دریافت دیتا برای نقشه و **هاور/tooltip** | -| ۴ | **دیتای تکمیلی زون** | با کلیک روی هر مربع، دریافت دیتای جزئیات (دلیل، معیارها، نمودار) | +| ۳ | **دیتای اولیه زون‌ها (محصولات)** | ارسال محدودهٔ مربع‌ها، دریافت محصول پیشنهادی برای نقشه و tooltip | +| ۴ | **نیاز آبی** | ارسال محدودهٔ مربع‌ها، دریافت نیاز آبی هر منطقه برای لایهٔ نیاز آبی | +| ۵ | **کیفیت خاک** | ارسال محدودهٔ مربع‌ها، دریافت کیفیت خاک هر منطقه برای لایهٔ کیفیت خاک | +| ۶ | **ریسک کشت** | ارسال محدودهٔ مربع‌ها، دریافت ریسک کشت هر منطقه برای لایهٔ ریسک کشت | +| ۷ | **دیتای تکمیلی زون** | با کلیک روی هر مربع، دریافت دیتای جزئیات (دلیل، معیارها، نمودار) | --- @@ -241,15 +247,164 @@ |------|-----|--------|--------| | `zoneId` | string | بله | شناسهٔ یکتا برای درخواست دیتای تکمیلی | | `geometry` | Polygon | بله | هندسهٔ همان مربع ارسالی | -| `crop` | string | بله | محصول پیشنهادی (رنگ نقشه + tooltip) | -| `matchPercent` | number | بله | درصد تطابق (هاور/tooltip) | -| `waterNeed` | string | بله | نیاز آبی (هاور/tooltip) | -| `estimatedProfit` | string | بله | سود تخمینی (هاور/tooltip) | +| `crop` | string \| null | خیر | محصول پیشنهادی؛ اگر `null`/خالی/`uncultivable` باشد → زون **غیرقابل کشت** و رنگ خاکستری | +| `matchPercent` | number | خیر | درصد تطابق (هاور/tooltip) | +| `waterNeed` | string | خیر | نیاز آبی (هاور/tooltip) | +| `estimatedProfit` | string | خیر | سود تخمینی (هاور/tooltip) | + +**زون غیرقابل کشت:** اگر برای مربعی اطلاعاتی نیاید یا `crop` خالی/`null`/`uncultivable` باشد، آن مربع خاکستری نمایش داده شده و در tooltip «غیر قابل کشت» نشان داده می‌شود. کلیک روی آن پنل جزئیات باز نمی‌شود. **نکته:** این فیلدها هنگام **هاور** روی مربع در tooltip نمایش داده می‌شوند؛ نیازی به درخواست جداگانه برای tooltip نیست. --- +## ۲.۱ API نیاز آبی (Water Need) + +نیاز آبی هر منطقه را بر اساس محدودهٔ مربع‌ها برمی‌گرداند. با تغییر لایه به «نیاز آبی» در LayerControl، فرانت این API را صدا می‌زند و نقشه و Legend را به‌روزرسانی می‌کند. + +### مشخصات + +- **متد:** `POST` +- **آدرس پیشنهادی:** `POST /api/crop-zoning/zones/water-need/` +- **هدف:** دریافت نیاز آبی هر منطقه برای نمایش روی نقشه در لایهٔ نیاز آبی. + +### ورودی (Request Body) + +همان ساختار `POST zones/initial/`: + +| فیلد | نوع | اجباری | توضیح | +|------|-----|--------|--------| +| `zones` | GeoJSON FeatureCollection | بله | محدودهٔ هر مربع به صورت Polygon | + +### خروجی (Response Body) + +```json +{ + "status": "success", + "data": { + "zones": [ + { + "zoneId": "zone-0", + "geometry": { "type": "Polygon", "coordinates": [...] }, + "level": "low", + "value": "۳۰۰۰-۴۰۰۰ m³/ha", + "color": "#7dd3fc" + }, + { + "zoneId": "zone-1", + "geometry": { "type": "Polygon", "coordinates": [...] }, + "level": "medium", + "value": "۵۰۰۰-۶۰۰۰ m³/ha", + "color": "#0ea5e9" + } + ] + } +} +``` + +| فیلد | نوع | توضیح | +|------|-----|--------| +| `zoneId` | string | شناسهٔ زون | +| `geometry` | Polygon | هندسهٔ مربع | +| `level` | string | سطح: `low`, `medium`, `high` | +| `value` | string | مقدار نیاز آبی (مثلاً m³/ha) | +| `color` | string | رنگ hex برای نمایش | + +--- + +## ۲.۲ API کیفیت خاک (Soil Quality) + +کیفیت خاک هر منطقه را برمی‌گرداند. با تغییر لایه به «کیفیت خاک»، فرانت این API را صدا می‌زند. + +### مشخصات + +- **متد:** `POST` +- **آدرس پیشنهادی:** `POST /api/crop-zoning/zones/soil-quality/` + +### ورودی (Request Body) + +همان `zones` (FeatureCollection). + +### خروجی (Response Body) + +```json +{ + "status": "success", + "data": { + "zones": [ + { + "zoneId": "zone-0", + "geometry": { "type": "Polygon", "coordinates": [...] }, + "level": "low", + "score": 35, + "color": "#f87171" + }, + { + "zoneId": "zone-1", + "geometry": { "type": "Polygon", "coordinates": [...] }, + "level": "high", + "score": 85, + "color": "#22c55e" + } + ] + } +} +``` + +| فیلد | نوع | توضیح | +|------|-----|--------| +| `level` | string | سطح: `low`, `medium`, `high` | +| `score` | number | امتیاز ۰–۱۰۰ | +| `color` | string | رنگ hex | + +--- + +## ۲.۳ API ریسک کشت (Cultivation Risk) + +ریسک کشت هر منطقه را برمی‌گرداند. با تغییر لایه به «ریسک کشت»، فرانت این API را صدا می‌زند. + +### مشخصات + +- **متد:** `POST` +- **آدرس پیشنهادی:** `POST /api/crop-zoning/zones/cultivation-risk/` + +### ورودی و خروجی + +ورودی: همان `zones` (FeatureCollection). + +خروجی نمونه: + +```json +{ + "status": "success", + "data": { + "zones": [ + { + "zoneId": "zone-0", + "geometry": { "type": "Polygon", "coordinates": [...] }, + "level": "low", + "color": "#22c55e" + }, + { + "zoneId": "zone-1", + "geometry": { "type": "Polygon", "coordinates": [...] }, + "level": "high", + "color": "#ef4444" + } + ] + } +} +``` + +| فیلد | نوع | توضیح | +|------|-----|--------| +| `level` | string | سطح: `low`, `medium`, `high` | +| `color` | string | رنگ hex | + +**نکته:** برای هر لایه (نیاز آبی، کیفیت خاک، ریسک کشت) فرانت یک **درخواست جداگانه** ارسال می‌کند و نقشه و Legend متناسب با همان لایه به‌روزرسانی می‌شوند. + +--- + ## ۳. API دیتای تکمیلی زون (با کلیک روی مربع) وقتی کاربر روی یک مربع کلیک می‌کند، فرانت با `zoneId` دیتای **تکمیلی** را درخواست می‌کند — برای نمایش پنل جزئیات: دلیل پیشنهاد، معیارها، نمودار راداری. @@ -369,8 +524,13 @@ const CROP_COLORS: Record = { ## ۶. جریان فرانت با APIها 1. **لود صفحه:** `GET /api/crop-zoning/products/` → لیست محصولات و رنگ‌ها. -2. **رسم منطقه / بهینه‌سازی:** فرانت با Turf از polygon منطقه گرید می‌سازد → `POST /api/crop-zoning/zones/initial/` با `zones` (FeatureCollection) → نقشه و هاور/tooltip با دیتای اولیه رسم می‌شود. -3. **کلیک روی مربع:** `GET /api/crop-zoning/zones/{zoneId}/details/` → دیتای تکمیلی → پنل جزئیات باز می‌شود (reason, criteria, نمودار راداری). +2. **رسم منطقه / بهینه‌سازی:** فرانت با Turf از polygon منطقه گرید می‌سازد → `POST /api/crop-zoning/zones/initial/` با `zones` (FeatureCollection) → نقشه و tooltip با دیتای محصولات رسم می‌شود. +3. **تغییر لایه در LayerControl:** برای هر لایه یک درخواست جداگانه ارسال می‌شود: + - محصولات پیشنهادی: `POST zones/initial/` (در مرحلهٔ ۲) + - نیاز آبی: `POST zones/water-need/` → نقشه و Legend به‌روزرسانی می‌شوند + - کیفیت خاک: `POST zones/soil-quality/` → نقشه و Legend به‌روزرسانی می‌شوند + - ریسک کشت: `POST zones/cultivation-risk/` → نقشه و Legend به‌روزرسانی می‌شوند +4. **کلیک روی مربع:** `GET /api/crop-zoning/zones/{zoneId}/details/` → دیتای تکمیلی → پنل جزئیات باز می‌شود (reason, criteria, نمودار راداری). --- diff --git a/src/libs/api/services/cropZoningService.ts b/src/libs/api/services/cropZoningService.ts index da99d97..392cbc8 100644 --- a/src/libs/api/services/cropZoningService.ts +++ b/src/libs/api/services/cropZoningService.ts @@ -17,10 +17,11 @@ export interface Product { export interface ZoneInitialData { zoneId: string geometry: Polygon - crop: string - matchPercent: number - waterNeed: string - estimatedProfit: string + /** اگر null/خالی/uncultivable باشد، زون غیرقابل کشت و خاکستری نمایش داده می‌شود */ + crop?: string | null + matchPercent?: number | null + waterNeed?: string | null + estimatedProfit?: string | null } export interface ZonesInitialResponse { @@ -45,6 +46,42 @@ export interface ZoneDetailData { area_hectares?: number } +/** دیتای نیاز آبی هر زون — لایهٔ نیاز آبی */ +export interface ZoneWaterNeedData { + zoneId: string + geometry: Polygon + level: 'low' | 'medium' | 'high' + value?: string + color: string +} + +/** دیتای کیفیت خاک هر زون — لایهٔ کیفیت خاک */ +export interface ZoneSoilQualityData { + zoneId: string + geometry: Polygon + level: 'low' | 'medium' | 'high' + score?: number + color: string +} + +/** دیتای ریسک کشت هر زون — لایهٔ ریسک کشت */ +export interface ZoneCultivationRiskData { + zoneId: string + geometry: Polygon + level: 'low' | 'medium' | 'high' + color: string +} + +/** دادهٔ نمایشی هر زون روی نقشه — خروجی تبدیل از تمام لایه‌ها */ +export interface ZoneMapData { + zoneId: string + geometry: Polygon + color: string + tooltipContent: string + cultivable: boolean + zoneInitialData?: ZoneInitialData +} + interface ApiResponse { status: string data: T @@ -73,5 +110,31 @@ export const cropZoningService = { getArea(): Promise { return unwrap(apiClient.get>(`${PREFIX}/area/`)) + }, + + /** نیاز آبی هر منطقه — برای لایهٔ نیاز آبی */ + getZonesWaterNeed(body: { zones: FeatureCollection }): Promise<{ zones: ZoneWaterNeedData[] }> { + return unwrap( + apiClient.post>(`${PREFIX}/zones/water-need/`, body) + ) + }, + + /** کیفیت خاک هر منطقه — برای لایهٔ کیفیت خاک */ + getZonesSoilQuality(body: { zones: FeatureCollection }): Promise<{ zones: ZoneSoilQualityData[] }> { + return unwrap( + apiClient.post>(`${PREFIX}/zones/soil-quality/`, body) + ) + }, + + /** ریسک کشت هر منطقه — برای لایهٔ ریسک کشت */ + getZonesCultivationRisk(body: { + zones: FeatureCollection + }): Promise<{ zones: ZoneCultivationRiskData[] }> { + return unwrap( + apiClient.post>( + `${PREFIX}/zones/cultivation-risk/`, + body + ) + ) } } diff --git a/src/views/dashboards/farm/cropZoning/CropZoningMap.tsx b/src/views/dashboards/farm/cropZoning/CropZoningMap.tsx index 6d8ca85..19657fb 100644 --- a/src/views/dashboards/farm/cropZoning/CropZoningMap.tsx +++ b/src/views/dashboards/farm/cropZoning/CropZoningMap.tsx @@ -5,8 +5,8 @@ import type L from 'leaflet' import 'leaflet/dist/leaflet.css' import 'leaflet-draw/dist/leaflet.draw.css' import type { Feature, Polygon } from 'geojson' -import { CROP_COLORS, type CropType, type LayerType, type ZoneFeatureProperties } from './cropZoningTypes' -import type { ZoneInitialData } from '@/libs/api/services/cropZoningService' +import type { LayerType } from './cropZoningTypes' +import type { ZoneMapData, ZoneInitialData } from '@/libs/api/services/cropZoningService' export type MapDrawGeoJSON = Record | null @@ -21,19 +21,14 @@ type CropZoningMapProps = { className?: string /** منطقهٔ اولیه؛ وقتی مقدار دارد و readOnly باشد نقشه فقط نمایشی است */ initialAreaGeoJson?: MapDrawGeoJSON | null - /** دیتای زون‌ها از API (POST zones/initial) */ - zonesData?: ZoneInitialData[] | null - /** لیبل محصولات از API (id -> label) برای tooltip */ + /** دیتای زون‌ها برای نقشه — از APIهای zones/initial یا water-need یا soil-quality یا cultivation-risk */ + zonesData?: ZoneMapData[] | null + /** لیبل محصولات از API (id -> label) — فقط برای لایهٔ crops */ productLabels?: Record /** غیرفعال کردن رسم و ویرایش منطقه توسط کاربر */ readOnly?: boolean } -const DEFAULT_PRODUCT_LABELS: Record = { - wheat: 'گندم', - canola: 'کلزا', - saffron: 'زعفران' -} export default function CropZoningMap({ center = [35.6892, 51.389], @@ -46,7 +41,7 @@ export default function CropZoningMap({ className = '', initialAreaGeoJson = null, zonesData = null, - productLabels = DEFAULT_PRODUCT_LABELS, + productLabels = {}, readOnly = false }: CropZoningMapProps) { const mapRef = useRef(null) @@ -56,14 +51,13 @@ export default function CropZoningMap({ const zonesLayerRef = useRef(null) const renderZonesFromApi = useCallback( - (map: L.Map, zones: ZoneInitialData[]) => { + (map: L.Map, zones: ZoneMapData[]) => { if (zonesLayerRef.current) { map.removeLayer(zonesLayerRef.current) zonesLayerRef.current = null } if (zones.length === 0) return - const labels = { ...DEFAULT_PRODUCT_LABELS, ...productLabels } const grid = { type: 'FeatureCollection' as const, features: zones.map(z => ({ @@ -71,10 +65,10 @@ export default function CropZoningMap({ geometry: z.geometry, properties: { zoneId: z.zoneId, - crop: z.crop, - matchPercent: z.matchPercent, - waterNeed: z.waterNeed, - estimatedProfit: z.estimatedProfit + color: z.color, + tooltipContent: z.tooltipContent, + cultivable: z.cultivable, + zoneInitialData: z.zoneInitialData } })) } @@ -83,48 +77,31 @@ export default function CropZoningMap({ if (!L) return const geoJsonLayer = L.geoJSON(grid as never, { - style: (feature?: { properties?: ZoneFeatureProperties }) => { - const props = feature?.properties - if (!props || activeLayer !== 'crops') { - return { fillColor: '#94a3b8', fillOpacity: 0.5, weight: 1, color: '#fff' } - } + style: (feature?: { properties?: { color?: string } }) => { + const color = feature?.properties?.color ?? '#94a3b8' return { - fillColor: (CROP_COLORS[props.crop as CropType] ?? '#94a3b8'), + fillColor: color, fillOpacity: 0.5, weight: 1, color: '#fff' } }, - onEachFeature: (feat: { geometry?: unknown; properties?: ZoneFeatureProperties }, leafLayer: L.Layer) => { - const feature = feat as { geometry: Polygon; properties?: ZoneFeatureProperties } - const props = feature?.properties - const geom = feature?.geometry + onEachFeature: (feat: unknown, leafLayer: L.Layer) => { + const f = feat as { geometry?: Polygon; properties?: Record } + const props = f?.properties + const geom = f?.geometry if (!props || !geom) return const layer = leafLayer as L.Polygon - const cropLabel = labels[props.crop] ?? props.crop - const tooltipContent = ` -
-
${cropLabel}
-
درصد تطابق: ${props.matchPercent}%
-
نیاز آب: ${props.waterNeed}
-
سود تخمینی: ${props.estimatedProfit}
-
- ` + const tooltipContent = (props.tooltipContent as string) ?? '' + const cultivable = props.cultivable === true + const zoneInitialData = props.zoneInitialData as ZoneInitialData | undefined layer.bindTooltip(tooltipContent, { sticky: true, className: 'zone-tooltip', direction: 'top', offset: [0, -8] }) - const zoneData: ZoneInitialData = { - zoneId: props.zoneId, - geometry: geom, - crop: props.crop, - matchPercent: props.matchPercent, - waterNeed: props.waterNeed, - estimatedProfit: props.estimatedProfit - } - layer.on('click', () => onZoneClick?.(zoneData)) + layer.on('click', () => cultivable && zoneInitialData && onZoneClick?.(zoneInitialData)) } }) @@ -143,7 +120,7 @@ export default function CropZoningMap({ }, delay) }) }, - [activeLayer, onZoneClick, productLabels] + [onZoneClick] ) useEffect(() => { @@ -268,23 +245,6 @@ export default function CropZoningMap({ } }, [zonesData, optimizationKey, renderZonesFromApi]) - useEffect(() => { - const zonesLayer = zonesLayerRef.current - if (!zonesLayer || !drawnItemsRef.current) return - const geojson = drawnItemsRef.current.toGeoJSON() - if (geojson.type !== 'FeatureCollection' || geojson.features.length === 0) return - zonesLayer.eachLayer((layer: L.Layer) => { - const leafLayer = layer as L.Polygon & { feature?: { properties: ZoneFeatureProperties } } - const props = leafLayer.feature?.properties - if (!props) return - leafLayer.setStyle({ - fillColor: activeLayer === 'crops' ? CROP_COLORS[props.crop as CropType] : '#94a3b8', - fillOpacity: 0.5, - weight: 1, - color: '#fff' - }) - }) - }, [activeLayer]) return (
(null) const [areaLoading, setAreaLoading] = useState(true) const [zonesData, setZonesData] = useState(null) + const [zonesWaterNeed, setZonesWaterNeed] = useState(null) + const [zonesSoilQuality, setZonesSoilQuality] = useState(null) + const [zonesCultivationRisk, setZonesCultivationRisk] = useState(null) const [products, setProducts] = useState([]) const [productsLoading, setProductsLoading] = useState(true) const [zonesLoading, setZonesLoading] = useState(false) + const [layerDataLoading, setLayerDataLoading] = useState(false) const [activeLayer, setActiveLayer] = useState('crops') const [selectedZone, setSelectedZone] = useState(null) const [panelOpen, setPanelOpen] = useState(false) @@ -65,8 +79,14 @@ export default function CropZoningWrapper() { const fetchZones = useCallback((geojson: MapDrawGeoJSON) => { if (!isPolygon(geojson)) { setZonesData(null) + setZonesWaterNeed(null) + setZonesSoilQuality(null) + setZonesCultivationRisk(null) return } + setZonesWaterNeed(null) + setZonesSoilQuality(null) + setZonesCultivationRisk(null) setZonesLoading(true) const grid = createGridFromPolygon(geojson as unknown as import('geojson').Feature) cropZoningService @@ -81,9 +101,113 @@ export default function CropZoningWrapper() { fetchZones(areaGeoJson) } else { setZonesData(null) + setZonesWaterNeed(null) + setZonesSoilQuality(null) + setZonesCultivationRisk(null) } }, [areaGeoJson, optimizationKey, fetchZones]) + const gridForLayers = isPolygon(areaGeoJson) + ? createGridFromPolygon(areaGeoJson as unknown as import('geojson').Feature) + : null + + useEffect(() => { + if (!gridForLayers || zonesLoading) return + if (activeLayer === 'waterNeed' && zonesWaterNeed === null) { + setLayerDataLoading(true) + cropZoningService + .getZonesWaterNeed({ zones: gridForLayers }) + .then(res => setZonesWaterNeed(res.zones)) + .catch(() => setZonesWaterNeed(null)) + .finally(() => setLayerDataLoading(false)) + } else if (activeLayer === 'soilQuality' && zonesSoilQuality === null) { + setLayerDataLoading(true) + cropZoningService + .getZonesSoilQuality({ zones: gridForLayers }) + .then(res => setZonesSoilQuality(res.zones)) + .catch(() => setZonesSoilQuality(null)) + .finally(() => setLayerDataLoading(false)) + } else if (activeLayer === 'cultivationRisk' && zonesCultivationRisk === null) { + setLayerDataLoading(true) + cropZoningService + .getZonesCultivationRisk({ zones: gridForLayers }) + .then(res => setZonesCultivationRisk(res.zones)) + .catch(() => setZonesCultivationRisk(null)) + .finally(() => setLayerDataLoading(false)) + } + }, [activeLayer, gridForLayers, zonesLoading, zonesWaterNeed, zonesSoilQuality, zonesCultivationRisk]) + + const mapZonesData = useMemo((): ZoneMapData[] | null => { + const labels = Object.fromEntries(products.map(p => [p.id, p.label])) + if (activeLayer === 'crops' && zonesData) { + const isCultivable = (crop: string | null | undefined) => + !!crop && crop !== 'uncultivable' && crop.toLowerCase() !== 'uncultivable' + return zonesData.map(z => { + const cultivable = isCultivable(z.crop) + const cropLabel = cultivable ? (labels[z.crop!] ?? z.crop) : 'غیر قابل کشت' + const tooltipContent = cultivable + ? `
+
${cropLabel}
+
درصد تطابق: ${z.matchPercent ?? '-'}%
+
نیاز آب: ${z.waterNeed ?? '-'}
+
سود تخمینی: ${z.estimatedProfit ?? '-'}
+
` + : `
+
غیر قابل کشت
+
این بخش برای کشت مناسب تشخیص داده نشده است.
+
` + const color = cultivable ? (CROP_COLORS[z.crop as CropType] ?? '#94a3b8') : '#94a3b8' + return { + zoneId: z.zoneId, + geometry: z.geometry, + color, + tooltipContent, + cultivable, + zoneInitialData: z + } + }) + } + if (activeLayer === 'waterNeed' && zonesWaterNeed) { + const levelLabels = { low: 'کم', medium: 'متوسط', high: 'زیاد' } + return zonesWaterNeed.map(z => ({ + zoneId: z.zoneId, + geometry: z.geometry, + color: z.color, + tooltipContent: `
+
نیاز آبی: ${levelLabels[z.level]}
+
${z.value ?? '-'}
+
`, + cultivable: false + })) + } + if (activeLayer === 'soilQuality' && zonesSoilQuality) { + const levelLabels = { low: 'کم', medium: 'متوسط', high: 'زیاد' } + return zonesSoilQuality.map(z => ({ + zoneId: z.zoneId, + geometry: z.geometry, + color: z.color, + tooltipContent: `
+
کیفیت خاک: ${levelLabels[z.level]}
+
امتیاز: ${z.score ?? '-'}
+
`, + cultivable: false + })) + } + if (activeLayer === 'cultivationRisk' && zonesCultivationRisk) { + const levelLabels = { low: 'کم', medium: 'متوسط', high: 'زیاد' } + return zonesCultivationRisk.map(z => ({ + zoneId: z.zoneId, + geometry: z.geometry, + color: z.color, + tooltipContent: `
+
ریسک کشت: ${levelLabels[z.level]}
+
`, + cultivable: false + })) + } + return null + }, [activeLayer, zonesData, zonesWaterNeed, zonesSoilQuality, zonesCultivationRisk, products]) + const handleAreaChange = useCallback((geojson: MapDrawGeoJSON) => { setAreaGeoJson(geojson) }, []) @@ -119,7 +243,7 @@ export default function CropZoningWrapper() { optimizationKey={optimizationKey} className='min-bs-[400px]' initialAreaGeoJson={areaGeoJson} - zonesData={zonesData} + zonesData={mapZonesData} productLabels={productLabels} readOnly /> @@ -128,7 +252,7 @@ export default function CropZoningWrapper() { )} - {(areaLoading || zonesLoading) && ( + {(areaLoading || zonesLoading || (activeLayer !== 'crops' && layerDataLoading)) && ( - + {areaGeoJson && ( diff --git a/src/views/dashboards/farm/cropZoning/LayerControl.tsx b/src/views/dashboards/farm/cropZoning/LayerControl.tsx index d1a3e9e..51b400a 100644 --- a/src/views/dashboards/farm/cropZoning/LayerControl.tsx +++ b/src/views/dashboards/farm/cropZoning/LayerControl.tsx @@ -2,6 +2,7 @@ import { useTranslations } from 'next-intl' import IconButton from '@mui/material/IconButton' +import { useTheme, alpha } from '@mui/material/styles' import type { LayerType } from './cropZoningTypes' const LAYER_ICONS: Record = { @@ -20,24 +21,34 @@ const LAYER_ORDER: LayerType[] = ['crops', 'waterNeed', 'soilQuality', 'cultivat export default function LayerControl({ activeLayer, onLayerChange }: LayerControlProps) { const t = useTranslations('cropZoning') + const theme = useTheme() + const primaryMain = theme.palette.primary.main return (
- {LAYER_ORDER.map(layer => ( - onLayerChange(layer)} - title={t(`layers.${layer}`)} - sx={{ - mx: 0.5, - bgcolor: activeLayer === layer ? 'action.selected' : 'transparent', - '&:hover': { bgcolor: activeLayer === layer ? 'action.selected' : 'action.hover' } - }} - > - - - ))} + {LAYER_ORDER.map(layer => { + const isActive = activeLayer === layer + return ( + onLayerChange(layer)} + title={t(`layers.${layer}`)} + sx={{ + mx: 0.5, + bgcolor: isActive ? alpha(primaryMain, 0.2) : 'transparent', + color: isActive ? primaryMain : 'text.secondary', + border: isActive ? `1px solid ${alpha(primaryMain, 0.5)}` : '1px solid transparent', + '&:hover': { + bgcolor: isActive ? alpha(primaryMain, 0.25) : 'action.hover', + color: isActive ? primaryMain : undefined + } + }} + > + + + ) + })}
) } diff --git a/src/views/dashboards/farm/cropZoning/ZoneLegend.tsx b/src/views/dashboards/farm/cropZoning/ZoneLegend.tsx index 56b2247..d039ca3 100644 --- a/src/views/dashboards/farm/cropZoning/ZoneLegend.tsx +++ b/src/views/dashboards/farm/cropZoning/ZoneLegend.tsx @@ -3,6 +3,7 @@ import { useTranslations } from 'next-intl' import { CROP_COLORS, type CropType } from './cropZoningTypes' import type { Product } from '@/libs/api/services/cropZoningService' +import type { LayerType } from './cropZoningTypes' const FALLBACK_ITEMS: { crop: CropType; label: string }[] = [ { crop: 'wheat', label: 'گندم' }, @@ -10,16 +11,66 @@ const FALLBACK_ITEMS: { crop: CropType; label: string }[] = [ { crop: 'saffron', label: 'زعفران' } ] +const WATER_NEED_ITEMS = [ + { level: 'low' as const, color: '#7dd3fc', labelKey: 'low' }, + { level: 'medium' as const, color: '#0ea5e9', labelKey: 'medium' }, + { level: 'high' as const, color: '#0369a1', labelKey: 'high' } +] + +const SOIL_QUALITY_ITEMS = [ + { level: 'low' as const, color: '#f87171', labelKey: 'low' }, + { level: 'medium' as const, color: '#fbbf24', labelKey: 'medium' }, + { level: 'high' as const, color: '#22c55e', labelKey: 'high' } +] + +const CULTIVATION_RISK_ITEMS = [ + { level: 'low' as const, color: '#22c55e', labelKey: 'low' }, + { level: 'medium' as const, color: '#f97316', labelKey: 'medium' }, + { level: 'high' as const, color: '#ef4444', labelKey: 'high' } +] + type ZoneLegendProps = { + activeLayer?: LayerType products?: Product[] loading?: boolean } -export default function ZoneLegend({ products = [], loading = false }: ZoneLegendProps) { +export default function ZoneLegend({ activeLayer = 'crops', products = [], loading = false }: ZoneLegendProps) { const t = useTranslations('cropZoning') - const items = products.length > 0 - ? products.map(p => ({ crop: p.id as CropType, label: p.label, color: p.color })) - : FALLBACK_ITEMS.map(({ crop }) => ({ crop, label: t(`crops.${crop}`), color: CROP_COLORS[crop] })) + + const items = + activeLayer === 'waterNeed' + ? WATER_NEED_ITEMS.map(({ level, color, labelKey }) => ({ + key: `water-${level}`, + label: t(`legendLevels.${labelKey}`), + color + })) + : activeLayer === 'soilQuality' + ? SOIL_QUALITY_ITEMS.map(({ level, color, labelKey }) => ({ + key: `soil-${level}`, + label: t(`legendLevels.${labelKey}`), + color + })) + : activeLayer === 'cultivationRisk' + ? CULTIVATION_RISK_ITEMS.map(({ level, color, labelKey }) => ({ + key: `risk-${level}`, + label: t(`legendLevels.${labelKey}`), + color + })) + : (() => { + const productItems = + products.length > 0 + ? products.map(p => ({ key: p.id, label: p.label, color: p.color })) + : FALLBACK_ITEMS.map(({ crop }) => ({ + key: crop, + label: t(`crops.${crop}`), + color: CROP_COLORS[crop] + })) + return [ + ...productItems, + { key: 'uncultivable', label: 'غیر قابل کشت', color: '#94a3b8' } + ] + })() return (
@@ -28,12 +79,9 @@ export default function ZoneLegend({ products = [], loading = false }: ZoneLegen
...
) : (
- {items.map(({ crop, label, color }) => ( -
-
+ {items.map(({ key, label, color }) => ( +
+
{label}
))}