diff --git a/src/app/(dashboard)/(private)/crop-zoning/CROP_ZONING_APIS.md b/src/app/(dashboard)/(private)/crop-zoning/CROP_ZONING_APIS.md new file mode 100644 index 0000000..d1a7a1f --- /dev/null +++ b/src/app/(dashboard)/(private)/crop-zoning/CROP_ZONING_APIS.md @@ -0,0 +1,384 @@ +# مستندات APIهای زون‌بندی کشت (Crop Zoning) + +این سند تمام APIهای مورد نیاز برای صفحه **Crop Zoning** را شرح می‌دهد: ورودی‌ها، خروجی‌ها، محصولات، رنگ‌ها، مساحت کلی و دیتای هر بخش زمین به صورت جداگانه. + +**مسیر صفحه:** `(dashboard)/(private)/crop-zoning` +**کامپوننت اصلی:** `CropZoningWrapper` + +--- + +## نمای کلی و جریان درخواست‌ها + +``` +۱. GET area → منطقهٔ ثابت (کاربر امکان رسم ندارد) +۲. GET products → لیست محصولات و رنگ‌ها +۳. POST zones/initial → ارسال محدودهٔ مربع‌ها → دریافت دیتای اولیه (نقشه + هاور/tooltip) +۴. GET zone/:zoneId → کلیک روی مربع → دریافت دیتای تکمیلی (پنل جزئیات: reason, criteria, ...) +``` + +| ردیف | API | هدف | +|------|-----|------| +| ۱ | **منطقهٔ اولیه** | دریافت منطقهٔ زمین به صورت GeoJSON؛ کاربر نمی‌تواند چیزی رسم کند | +| ۲ | **لیست محصولات و رنگ‌ها** | دریافت محصولات قابل کشت به همراه رنگ نمایش و لیبل فارسی | +| ۳ | **دیتای اولیه زون‌ها** | ارسال محدودهٔ مربع‌ها، دریافت دیتا برای نقشه و **هاور/tooltip** | +| ۴ | **دیتای تکمیلی زون** | با کلیک روی هر مربع، دریافت دیتای جزئیات (دلیل، معیارها، نمودار) | + +--- + +## ۰. API منطقهٔ اولیه (Area) + +منطقهٔ ثابت زمین که از بک‌اند دریافت می‌شود. کاربر امکان رسم یا ویرایش منطقه را ندارد. + +### ۰.۱ مشخصات + +- **متد:** `GET` +- **آدرس پیشنهادی:** `GET /api/crop-zoning/area/` +- **هدف:** دریافت polygon منطقهٔ زمین برای نمایش روی نقشه. + +### ۰.۲ ورودی (Request) + +بدون پارامتر. + +### ۰.۳ خروجی (Response Body) + +```json +{ + "status": "success", + "data": { + "area": { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [51.38, 35.68], + [51.40, 35.68], + [51.40, 35.70], + [51.38, 35.70], + [51.38, 35.68] + ] + ] + } + } + } +} +``` + +- **مختصات:** `[longitude, latitude]` طبق استاندارد GeoJSON + +--- + +## ۱. API لیست محصولات و رنگ‌ها + +برای نمایش راهنمای رنگ‌ها (Legend) و dropdown انتخاب محصول در پنل جزئیات هر زون. + +### ۱.۱ مشخصات + +- **متد:** `GET` +- **آدرس پیشنهادی:** `GET /api/crop-zoning/products/` یا `GET /api/crops/` +- **هدف:** دریافت لیست محصولات قابل کشت با رنگ و لیبل نمایشی. + +### ۱.۲ ورودی (Request) + +بدون پارامتر یا با پارامترهای اختیاری: + +| پارامتر | نوع | اجباری | توضیح | +|---------|-----|--------|--------| +| `locale` | string | خیر | کد زبان برای لیبل‌ها (مثلاً `fa`, `en`) | + +### ۱.۳ خروجی (Response) + +```json +{ + "status": "success", + "data": { + "products": [ + { + "id": "wheat", + "label": "گندم", + "color": "#6bcb77" + }, + { + "id": "canola", + "label": "کلزا", + "color": "#ffd93d" + }, + { + "id": "saffron", + "label": "زعفران", + "color": "#9b59b6" + } + ] + } +} +``` + +**ساختار هر محصول:** + +| فیلد | نوع | اجباری | توضیح | +|------|-----|--------|--------| +| `id` | string | بله | شناسهٔ یکتا (مثلاً `wheat`, `canola`, `saffron`) | +| `label` | string | بله | نام نمایشی به زبان کاربر | +| `color` | string | بله | رنگ hex برای نمایش روی نقشه و Legend | + +--- + +## ۲. API دیتای اولیه زون‌ها + +با رسم منطقهٔ زمین، فرانت **محدودهٔ همهٔ مربع‌ها** (گرید داخل polygon) را ارسال می‌کند و **دیتای اولیه** هر مربع را دریافت می‌کند — برای نمایش نقشه، رنگ‌بندی، و **هاور/tooltip**. این دیتا شامل `reason` و `criteria` **نیست**. + +### ۲.۱ مشخصات + +- **متد:** `POST` +- **آدرس پیشنهادی:** `POST /api/crop-zoning/zones/initial/` +- **هدف:** ارسال محدودهٔ مربع‌ها، دریافت دیتای اولیه برای نقشه، هاور و tooltip. + +### ۲.۲ ورودی (Request Body) + +فرانت ابتدا با Turf.js از روی polygon منطقه گرید می‌سازد، سپس `FeatureCollection` همهٔ polygonهای مربع‌ها را ارسال می‌کند. + +| فیلد | نوع | اجباری | توضیح | +|------|-----|--------|--------| +| `zones` | GeoJSON FeatureCollection | بله | محدودهٔ هر مربع به صورت Polygon؛ ترتیب index با پاسخ یکسان است | +| `products` | string[] | خیر | لیست محصولات مدنظر؛ در صورت عدم ارسال از همهٔ محصولات استفاده شود | + +**ساختار `zones` (محدودهٔ همهٔ مربع‌ها):** + +```json +{ + "zones": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [51.38, 35.68], + [51.3815, 35.68], + [51.3815, 35.6815], + [51.38, 35.6815], + [51.38, 35.68] + ] + ] + }, + "properties": { "index": 0 } + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [51.3815, 35.68], + [51.383, 35.68], + [51.383, 35.6815], + [51.3815, 35.6815], + [51.3815, 35.68] + ] + ] + }, + "properties": { "index": 1 } + } + ] + }, + "products": ["wheat", "canola", "saffron"] +} +``` + +- **مختصات:** `[longitude, latitude]` طبق استاندارد GeoJSON +- **index:** در `properties` هر feature برای تطابق با پاسخ (اختیاری؛ در صورت نبودن از ترتیب آرایه استفاده شود) + +### ۲.۳ خروجی (Response Body) — دیتای اولیه + +فقط فیلدهای لازم برای **نقشه**، **هاور** و **tooltip** (نمایش هنگام عبور ماوس روی هر مربع)؛ بدون `reason` و `criteria`. + +```json +{ + "status": "success", + "data": { + "total_area_hectares": 23.45, + "total_area_sqm": 234500, + "zone_count": 3, + "zones": [ + { + "zoneId": "zone-0", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [51.38, 35.68], + [51.3815, 35.68], + [51.3815, 35.6815], + [51.38, 35.6815], + [51.38, 35.68] + ] + ] + }, + "crop": "wheat", + "matchPercent": 85, + "waterNeed": "۴۵۰۰-۵۵۰۰ m³/ha", + "estimatedProfit": "۱۵-۲۵ میلیون/هکتار" + }, + { + "zoneId": "zone-1", + "geometry": { "type": "Polygon", "coordinates": [...] }, + "crop": "canola", + "matchPercent": 78, + "waterNeed": "۵۰۰۰-۶۰۰۰ m³/ha", + "estimatedProfit": "۲۰-۳۵ میلیون/هکتار" + } + ] + } +} +``` + +**ساختار دیتای اولیه هر زون (هم برای نقشه هم برای هاور/tooltip):** + +| فیلد | نوع | اجباری | توضیح | +|------|-----|--------|--------| +| `zoneId` | string | بله | شناسهٔ یکتا برای درخواست دیتای تکمیلی | +| `geometry` | Polygon | بله | هندسهٔ همان مربع ارسالی | +| `crop` | string | بله | محصول پیشنهادی (رنگ نقشه + tooltip) | +| `matchPercent` | number | بله | درصد تطابق (هاور/tooltip) | +| `waterNeed` | string | بله | نیاز آبی (هاور/tooltip) | +| `estimatedProfit` | string | بله | سود تخمینی (هاور/tooltip) | + +**نکته:** این فیلدها هنگام **هاور** روی مربع در tooltip نمایش داده می‌شوند؛ نیازی به درخواست جداگانه برای tooltip نیست. + +--- + +## ۳. API دیتای تکمیلی زون (با کلیک روی مربع) + +وقتی کاربر روی یک مربع کلیک می‌کند، فرانت با `zoneId` دیتای **تکمیلی** را درخواست می‌کند — برای نمایش پنل جزئیات: دلیل پیشنهاد، معیارها، نمودار راداری. + +### ۳.۱ مشخصات + +- **متد:** `GET` +- **آدرس پیشنهادی:** `GET /api/crop-zoning/zones/:zoneId/details/` +- **هدف:** دریافت دیتای تکمیلی یک زون برای پنل جزئیات. + +### ۳.۲ ورودی (Request) + +| پارامتر | محل | نوع | اجباری | توضیح | +|---------|------|-----|--------|--------| +| `zoneId` | path | string | بله | شناسهٔ زون (مثلاً `zone-0`) | + +**مثال:** `GET /api/crop-zoning/zones/zone-0/details/` + +### ۳.۳ خروجی (Response Body) + +```json +{ + "status": "success", + "data": { + "zoneId": "zone-0", + "crop": "wheat", + "matchPercent": 85, + "waterNeed": "۴۵۰۰-۵۵۰۰ m³/ha", + "estimatedProfit": "۱۵-۲۵ میلیون/هکتار", + "reason": "دمای مناسب، خاک حاصلخیز، دسترسی به آب کافی", + "criteria": [ + { "name": "دما", "value": 82 }, + { "name": "بارش", "value": 75 }, + { "name": "خاک", "value": 88 }, + { "name": "آب", "value": 70 } + ], + "area_hectares": 2.25 + } +} +``` + +**فیلدهای دیتای تکمیلی:** + +| فیلد | نوع | اجباری | توضیح | +|------|-----|--------|--------| +| `zoneId` | string | بله | همان zoneId درخواست | +| `crop` | string | بله | محصول پیشنهادی | +| `matchPercent` | number | بله | درصد تطابق | +| `waterNeed` | string | بله | نیاز آبی | +| `estimatedProfit` | string | بله | سود تخمینی | +| `reason` | string | بله | **فقط در دیتای تکمیلی** — دلیل پیشنهاد محصول | +| `criteria` | object[] | بله | **فقط در دیتای تکمیلی** — معیارها برای نمودار راداری | +| `area_hectares` | number | خیر | مساحت این زون بر حسب هکتار | + +### ۳.۴ ساختار `criteria` + +| فیلد | نوع | توضیح | +|------|-----|--------| +| `name` | string | نام معیار (دما، بارش، خاک، آب) | +| `value` | number | امتیاز ۰–۱۰۰ | + +--- + +## ۴. مساحت کلی (Total Area) + +در پاسخ **API دیتای اولیه زون‌ها** برمی‌گردد: + +| فیلد | نوع | توضیح | +|------|-----|--------| +| `total_area_hectares` | number | مساحت کل منطقه بر حسب هکتار | +| `total_area_sqm` | number | مساحت کل بر حسب متر مربع | + +--- + +## ۵. خلاصهٔ ساختار‌های مورد نیاز فرانت + +### دیتای اولیه زون (برای نقشه و هاور/tooltip) + +```ts +interface ZoneInitialData { + zoneId: string + geometry: Polygon + crop: string + matchPercent: number + waterNeed: string + estimatedProfit: string +} +``` + +### دیتای تکمیلی زون (برای پنل جزئیات — پس از کلیک) + +```ts +interface ZoneDetailData { + zoneId: string + crop: string + matchPercent: number + waterNeed: string + estimatedProfit: string + reason: string + criteria: { name: string; value: number }[] + area_hectares?: number +} +``` + +### محصولات و رنگ‌ها (پیش‌فرض فرانت) + +```ts +const CROP_COLORS: Record = { + wheat: '#6bcb77', + canola: '#ffd93d', + saffron: '#9b59b6' +} +``` + +--- + +## ۶. جریان فرانت با 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, نمودار راداری). + +--- + +## ۷. وضعیت فعلی و نیازمندی‌ها + +- در حال حاضر زون‌بندی با **دیتای ماک** و الگوریتم محلی (`createZonedGrid` در `cropZoningUtils.ts`) کار می‌کند. +- برای اتصال به بک‌اند، لازم است: + 1. سرویس `cropZoningService` با سه endpoint: `getProducts()`, `getZonesInitial(zones)`, `getZoneDetails(zoneId)` ایجاد شود. + 2. در `CropZoningMap` به جای `createZonedGrid` ابتدا گرید با Turf ساخته شود، سپس `zones` به API ارسال و پاسخ برای رسم استفاده شود. + 3. در `onZoneClick` قبل از باز کردن پنل، `getZoneDetails(zoneId)` صدا زده شود و دیتای تکمیلی به `ZoneDetailPanel` پاس داده شود. + 4. مساحت کلی (`total_area_hectares`) در پاسخ initial در UI نمایش داده شود. diff --git a/src/app/(dashboard)/(private)/crop-zoning/cropZoningMockData.json b/src/app/(dashboard)/(private)/crop-zoning/cropZoningMockData.json new file mode 100644 index 0000000..8a165a5 --- /dev/null +++ b/src/app/(dashboard)/(private)/crop-zoning/cropZoningMockData.json @@ -0,0 +1,206 @@ +{ + "_comment": "دیتای ماک مطابق CROP_ZONING_APIS.md", + "_source": "CROP_ZONING_APIS.md", + + "area": { + "description": "GET /api/crop-zoning/area/ — منطقهٔ اولیه (کاربر نمی‌تواند رسم کند)", + "response": { + "status": "success", + "data": { + "area": { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [[51.38, 35.68], [51.40, 35.68], [51.40, 35.70], [51.38, 35.70], [51.38, 35.68]] + ] + } + } + } + } + }, + + "products": { + "description": "GET /api/crop-zoning/products/", + "response": { + "status": "success", + "data": { + "products": [ + { "id": "wheat", "label": "گندم", "color": "#6bcb77" }, + { "id": "canola", "label": "کلزا", "color": "#ffd93d" }, + { "id": "saffron", "label": "زعفران", "color": "#9b59b6" } + ] + } + } + }, + + "zonesInitialRequest": { + "description": "POST /api/crop-zoning/zones/initial/ — Request Body", + "body": { + "zones": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]] + ] + }, + "properties": { "index": 0 } + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]] + ] + }, + "properties": { "index": 1 } + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]] + ] + }, + "properties": { "index": 2 } + } + ] + }, + "products": ["wheat", "canola", "saffron"] + } + }, + + "zonesInitialResponse": { + "description": "POST /api/crop-zoning/zones/initial/ — Response", + "response": { + "status": "success", + "data": { + "total_area_hectares": 23.45, + "total_area_sqm": 234500, + "zone_count": 3, + "zones": [ + { + "zoneId": "zone-0", + "geometry": { + "type": "Polygon", + "coordinates": [ + [[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]] + ] + }, + "crop": "wheat", + "matchPercent": 85, + "waterNeed": "۴۵۰۰-۵۵۰۰ m³/ha", + "estimatedProfit": "۱۵-۲۵ میلیون/هکتار" + }, + { + "zoneId": "zone-1", + "geometry": { + "type": "Polygon", + "coordinates": [ + [[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]] + ] + }, + "crop": "canola", + "matchPercent": 78, + "waterNeed": "۵۰۰۰-۶۰۰۰ m³/ha", + "estimatedProfit": "۲۰-۳۵ میلیون/هکتار" + }, + { + "zoneId": "zone-2", + "geometry": { + "type": "Polygon", + "coordinates": [ + [[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]] + ] + }, + "crop": "saffron", + "matchPercent": 92, + "waterNeed": "۳۰۰۰-۴۰۰۰ m³/ha", + "estimatedProfit": "۵۰-۱۵۰ میلیون/هکتار" + } + ] + } + } + }, + + "zoneDetails": { + "description": "GET /api/crop-zoning/zones/:zoneId/details/ — Response per zone", + "responses": { + "zone-0": { + "status": "success", + "data": { + "zoneId": "zone-0", + "crop": "wheat", + "matchPercent": 85, + "waterNeed": "۴۵۰۰-۵۵۰۰ m³/ha", + "estimatedProfit": "۱۵-۲۵ میلیون/هکتار", + "reason": "دمای مناسب، خاک حاصلخیز، دسترسی به آب کافی", + "criteria": [ + { "name": "دما", "value": 82 }, + { "name": "بارش", "value": 75 }, + { "name": "خاک", "value": 88 }, + { "name": "آب", "value": 70 } + ], + "area_hectares": 2.25 + } + }, + "zone-1": { + "status": "success", + "data": { + "zoneId": "zone-1", + "crop": "canola", + "matchPercent": 78, + "waterNeed": "۵۰۰۰-۶۰۰۰ m³/ha", + "estimatedProfit": "۲۰-۳۵ میلیون/هکتار", + "reason": "شرایط اقلیمی مساعد، نیاز آبی قابل تأمین", + "criteria": [ + { "name": "دما", "value": 75 }, + { "name": "بارش", "value": 72 }, + { "name": "خاک", "value": 80 }, + { "name": "آب", "value": 78 } + ], + "area_hectares": 2.25 + } + }, + "zone-2": { + "status": "success", + "data": { + "zoneId": "zone-2", + "crop": "saffron", + "matchPercent": 92, + "waterNeed": "۳۰۰۰-۴۰۰۰ m³/ha", + "estimatedProfit": "۵۰-۱۵۰ میلیون/هکتار", + "reason": "ارتفاع و آب و هوای خشک مناسب، پتانسیل سود بالا", + "criteria": [ + { "name": "دما", "value": 90 }, + { "name": "بارش", "value": 65 }, + { "name": "خاک", "value": 95 }, + { "name": "آب", "value": 85 } + ], + "area_hectares": 2.25 + } + } + } + }, + + "areaGeoJson": { + "description": "منطقهٔ ثابت (MOCK_AREA_GEOJSON) — polygon اصلی نقشه", + "feature": { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [[51.38, 35.68], [51.40, 35.68], [51.40, 35.70], [51.38, 35.70], [51.38, 35.68]] + ] + } + } + } +} diff --git a/src/libs/api/services/cropZoningService.ts b/src/libs/api/services/cropZoningService.ts new file mode 100644 index 0000000..da99d97 --- /dev/null +++ b/src/libs/api/services/cropZoningService.ts @@ -0,0 +1,77 @@ +/** + * Crop Zoning API + * @see CROP_ZONING_APIS.md + */ + +import type { Feature, FeatureCollection, Polygon } from 'geojson' +import { apiClient } from '../client' + +const PREFIX = '/api/crop-zoning' + +export interface Product { + id: string + label: string + color: string +} + +export interface ZoneInitialData { + zoneId: string + geometry: Polygon + crop: string + matchPercent: number + waterNeed: string + estimatedProfit: string +} + +export interface ZonesInitialResponse { + total_area_hectares: number + total_area_sqm: number + zone_count: number + zones: ZoneInitialData[] +} + +export interface AreaResponse { + area: Feature +} + +export interface ZoneDetailData { + zoneId: string + crop: string + matchPercent: number + waterNeed: string + estimatedProfit: string + reason: string + criteria: { name: string; value: number }[] + area_hectares?: number +} + +interface ApiResponse { + status: string + data: T +} + +async function unwrap(promise: Promise>): Promise { + const res = await promise + return res.data +} + +export const cropZoningService = { + getProducts(): Promise<{ products: Product[] }> { + return unwrap(apiClient.get>(`${PREFIX}/products/`)) + }, + + getZonesInitial(body: { + zones: FeatureCollection + products?: string[] + }): Promise { + return unwrap(apiClient.post>(`${PREFIX}/zones/initial/`, body)) + }, + + getZoneDetails(zoneId: string): Promise { + return unwrap(apiClient.get>(`${PREFIX}/zones/${zoneId}/details/`)) + }, + + getArea(): Promise { + return unwrap(apiClient.get>(`${PREFIX}/area/`)) + } +} diff --git a/src/views/dashboards/farm/cropZoning/CropZoningMap.tsx b/src/views/dashboards/farm/cropZoning/CropZoningMap.tsx index 2969106..6d8ca85 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 { createZonedGrid } from './cropZoningUtils' import { CROP_COLORS, type CropType, type LayerType, type ZoneFeatureProperties } from './cropZoningTypes' +import type { ZoneInitialData } from '@/libs/api/services/cropZoningService' export type MapDrawGeoJSON = Record | null @@ -16,15 +16,25 @@ type CropZoningMapProps = { height?: string | number activeLayer: LayerType onAreaChange?: (geojson: MapDrawGeoJSON) => void - onZoneClick?: (zone: ZoneFeatureProperties) => void + onZoneClick?: (zone: ZoneInitialData) => void optimizationKey?: number className?: string - /** منطقهٔ اولیه از دیتای ماک؛ وقتی مقدار دارد نقشه فقط نمایشی است و کاربر نمیتواند منطقه را تغییر دهد */ + /** منطقهٔ اولیه؛ وقتی مقدار دارد و readOnly باشد نقشه فقط نمایشی است */ initialAreaGeoJson?: MapDrawGeoJSON | null + /** دیتای زون‌ها از API (POST zones/initial) */ + zonesData?: ZoneInitialData[] | null + /** لیبل محصولات از API (id -> label) برای tooltip */ + productLabels?: Record /** غیرفعال کردن رسم و ویرایش منطقه توسط کاربر */ readOnly?: boolean } +const DEFAULT_PRODUCT_LABELS: Record = { + wheat: 'گندم', + canola: 'کلزا', + saffron: 'زعفران' +} + export default function CropZoningMap({ center = [35.6892, 51.389], zoom = 13, @@ -35,6 +45,8 @@ export default function CropZoningMap({ optimizationKey = 0, className = '', initialAreaGeoJson = null, + zonesData = null, + productLabels = DEFAULT_PRODUCT_LABELS, readOnly = false }: CropZoningMapProps) { const mapRef = useRef(null) @@ -43,14 +55,30 @@ export default function CropZoningMap({ const drawControlRef = useRef(null) const zonesLayerRef = useRef(null) - const renderZones = useCallback( - (map: L.Map, polygonFeature: Feature) => { + const renderZonesFromApi = useCallback( + (map: L.Map, zones: ZoneInitialData[]) => { 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 => ({ + type: 'Feature' as const, + geometry: z.geometry, + properties: { + zoneId: z.zoneId, + crop: z.crop, + matchPercent: z.matchPercent, + waterNeed: z.waterNeed, + estimatedProfit: z.estimatedProfit + } + })) + } - const grid = createZonedGrid(polygonFeature) const L = (window as unknown as { L: typeof import('leaflet') }).L if (!L) return @@ -61,17 +89,19 @@ export default function CropZoningMap({ return { fillColor: '#94a3b8', fillOpacity: 0.5, weight: 1, color: '#fff' } } return { - fillColor: CROP_COLORS[props.crop as CropType], + fillColor: (CROP_COLORS[props.crop as CropType] ?? '#94a3b8'), fillOpacity: 0.5, weight: 1, color: '#fff' } }, - onEachFeature: (feature: { properties?: ZoneFeatureProperties }, leafLayer: L.Layer) => { + onEachFeature: (feat: { geometry?: unknown; properties?: ZoneFeatureProperties }, leafLayer: L.Layer) => { + const feature = feat as { geometry: Polygon; properties?: ZoneFeatureProperties } const props = feature?.properties - if (!props) return + const geom = feature?.geometry + if (!props || !geom) return const layer = leafLayer as L.Polygon - const cropLabel = props.crop === 'wheat' ? 'گندم' : props.crop === 'canola' ? 'کلزا' : 'زعفران' + const cropLabel = labels[props.crop] ?? props.crop const tooltipContent = `
${cropLabel}
@@ -86,7 +116,15 @@ export default function CropZoningMap({ direction: 'top', offset: [0, -8] }) - layer.on('click', () => onZoneClick?.(props)) + 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)) } }) @@ -105,7 +143,7 @@ export default function CropZoningMap({ }, delay) }) }, - [activeLayer, onZoneClick] + [activeLayer, onZoneClick, productLabels] ) useEffect(() => { @@ -155,12 +193,10 @@ export default function CropZoningMap({ return null } - const emitAndRender = () => { + const emitAreaChange = () => { const geojson = getGeoJsonFromDrawn() onAreaChange?.(geojson) - if (geojson && geojson.geometry && (geojson.geometry as { type: string }).type === 'Polygon') { - renderZones(map, geojson as Feature) - } else if (zonesLayerRef.current) { + if (!geojson && zonesLayerRef.current) { map.removeLayer(zonesLayerRef.current) zonesLayerRef.current = null } @@ -168,18 +204,18 @@ export default function CropZoningMap({ if (readOnly && initialAreaGeoJson && initialAreaGeoJson.geometry && (initialAreaGeoJson.geometry as { type: string }).type === 'Polygon') { drawnItems.clearLayers() - L.geoJSON(initialAreaGeoJson as Feature).eachLayer((layer) => drawnItems.addLayer(layer)) - emitAndRender() + L.geoJSON(initialAreaGeoJson as unknown as Feature).eachLayer((layer) => drawnItems.addLayer(layer)) + emitAreaChange() } const onCreated = (e: L.LeafletEvent) => { const event = e as L.DrawEvents.Created drawnItems.clearLayers() drawnItems.addLayer(event.layer) - emitAndRender() + emitAreaChange() } - const onEdited = () => emitAndRender() + const onEdited = () => emitAreaChange() const onDeleted = () => { onAreaChange?.(null) if (zonesLayerRef.current) { @@ -223,13 +259,14 @@ export default function CropZoningMap({ }, []) useEffect(() => { - if (!mapInstanceRef.current || !drawnItemsRef.current || optimizationKey === 0) return - const geojson = drawnItemsRef.current.toGeoJSON() - if (geojson.type === 'FeatureCollection' && geojson.features.length > 0) { - const feature = geojson.features[0] as Feature - renderZones(mapInstanceRef.current, feature) + if (!mapInstanceRef.current) return + if (zonesData && zonesData.length > 0) { + renderZonesFromApi(mapInstanceRef.current, zonesData) + } else if (zonesLayerRef.current) { + mapInstanceRef.current.removeLayer(zonesLayerRef.current) + zonesLayerRef.current = null } - }, [optimizationKey, activeLayer, renderZones]) + }, [zonesData, optimizationKey, renderZonesFromApi]) useEffect(() => { const zonesLayer = zonesLayerRef.current diff --git a/src/views/dashboards/farm/cropZoning/CropZoningWrapper.tsx b/src/views/dashboards/farm/cropZoning/CropZoningWrapper.tsx index 01dbfc1..37ae303 100644 --- a/src/views/dashboards/farm/cropZoning/CropZoningWrapper.tsx +++ b/src/views/dashboards/farm/cropZoning/CropZoningWrapper.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useCallback } from 'react' +import { useState, useCallback, useEffect } from 'react' import dynamic from 'next/dynamic' import { useTranslations } from 'next-intl' import Box from '@mui/material/Box' @@ -11,9 +11,9 @@ import ZoneLegend from './ZoneLegend' import LayerControl from './LayerControl' import ZoneDetailPanel from './ZoneDetailPanel' import CropZoningWeatherSection from './CropZoningWeatherSection' -import { MOCK_AREA_GEOJSON } from './cropZoningMockData' +import { createGridFromPolygon } from './cropZoningUtils' +import { cropZoningService, type Product, type ZoneInitialData, type ZoneDetailData } from '@/libs/api/services/cropZoningService' import type { LayerType } from './cropZoningTypes' -import type { ZoneFeatureProperties } from './cropZoningTypes' import type { MapDrawGeoJSON } from './CropZoningMap' const MapComponent = dynamic(() => Promise.resolve(CropZoningMap), { @@ -25,21 +25,78 @@ const MapComponent = dynamic(() => Promise.resolve(CropZoningMap), { ) }) +function isPolygon(geojson: MapDrawGeoJSON): geojson is MapDrawGeoJSON & { geometry: { type: 'Polygon'; coordinates: unknown[] } } { + return !!geojson?.geometry && (geojson.geometry as { type: string }).type === 'Polygon' +} + export default function CropZoningWrapper() { const t = useTranslations('cropZoning') - const [areaGeoJson, setAreaGeoJson] = useState(MOCK_AREA_GEOJSON) + const [areaGeoJson, setAreaGeoJson] = useState(null) + const [areaLoading, setAreaLoading] = useState(true) + const [zonesData, setZonesData] = useState(null) + const [products, setProducts] = useState([]) + const [productsLoading, setProductsLoading] = useState(true) + const [zonesLoading, setZonesLoading] = useState(false) const [activeLayer, setActiveLayer] = useState('crops') - const [selectedZone, setSelectedZone] = useState(null) + const [selectedZone, setSelectedZone] = useState(null) const [panelOpen, setPanelOpen] = useState(false) + const [zoneDetailLoading, setZoneDetailLoading] = useState(false) const [optimizationKey, setOptimizationKey] = useState(0) + const productLabels = Object.fromEntries(products.map(p => [p.id, p.label])) + + useEffect(() => { + cropZoningService + .getProducts() + .then(res => setProducts(res.products)) + .catch(() => setProducts([])) + .finally(() => setProductsLoading(false)) + }, []) + + useEffect(() => { + setAreaLoading(true) + cropZoningService + .getArea() + .then(res => setAreaGeoJson(res.area as unknown as MapDrawGeoJSON)) + .catch(() => setAreaGeoJson(null)) + .finally(() => setAreaLoading(false)) + }, []) + + const fetchZones = useCallback((geojson: MapDrawGeoJSON) => { + if (!isPolygon(geojson)) { + setZonesData(null) + return + } + setZonesLoading(true) + const grid = createGridFromPolygon(geojson as unknown as import('geojson').Feature) + cropZoningService + .getZonesInitial({ zones: grid }) + .then(res => setZonesData(res.zones)) + .catch(() => setZonesData(null)) + .finally(() => setZonesLoading(false)) + }, []) + + useEffect(() => { + if (isPolygon(areaGeoJson)) { + fetchZones(areaGeoJson) + } else { + setZonesData(null) + } + }, [areaGeoJson, optimizationKey, fetchZones]) + const handleAreaChange = useCallback((geojson: MapDrawGeoJSON) => { setAreaGeoJson(geojson) }, []) - const handleZoneClick = useCallback((zone: ZoneFeatureProperties) => { - setSelectedZone(zone) + const handleZoneClick = useCallback((zone: ZoneInitialData) => { + setZoneDetailLoading(true) setPanelOpen(true) + setSelectedZone(null) + cropZoningService + .getZoneDetails(zone.zoneId) + .then(details => setSelectedZone(details)) + .catch(() => setSelectedZone(null)) + .finally(() => setZoneDetailLoading(false)) }, []) const handleOptimize = useCallback(() => { @@ -50,46 +107,64 @@ export default function CropZoningWrapper() { - - + {areaGeoJson ? ( + + ) : ( + + )} + - + {(areaLoading || zonesLoading) && ( + + + + )} - {areaGeoJson && ( - <> - + + + + + {areaGeoJson && ( - - )} + )} + setPanelOpen(false)} zone={selectedZone} + products={products} + loading={zoneDetailLoading} /> - diff --git a/src/views/dashboards/farm/cropZoning/ZoneDetailPanel.tsx b/src/views/dashboards/farm/cropZoning/ZoneDetailPanel.tsx index 1ffdef6..3e412ec 100644 --- a/src/views/dashboards/farm/cropZoning/ZoneDetailPanel.tsx +++ b/src/views/dashboards/farm/cropZoning/ZoneDetailPanel.tsx @@ -14,6 +14,7 @@ import Select from '@mui/material/Select' import MenuItem from '@mui/material/MenuItem' import FormControl from '@mui/material/FormControl' import InputLabel from '@mui/material/InputLabel' +import CircularProgress from '@mui/material/CircularProgress' import { Radar, RadarChart, @@ -22,17 +23,20 @@ import { PolarRadiusAxis, ResponsiveContainer } from 'recharts' -import type { ZoneFeatureProperties } from './cropZoningTypes' import { CROP_COLORS } from './cropZoningTypes' +import type { Product } from '@/libs/api/services/cropZoningService' +import type { ZoneDetailData } from '@/libs/api/services/cropZoningService' type ZoneDetailPanelProps = { open: boolean onClose: () => void - zone: ZoneFeatureProperties | null + zone: ZoneDetailData | null + products?: Product[] + loading?: boolean onCropChange?: (zoneId: string, crop: string) => void } -const CROP_LABELS: Record = { +const FALLBACK_LABELS: Record = { wheat: 'گندم', canola: 'کلزا', saffron: 'زعفران' @@ -42,6 +46,8 @@ export default function ZoneDetailPanel({ open, onClose, zone, + products = [], + loading = false, onCropChange }: ZoneDetailPanelProps) { const t = useTranslations('cropZoning') @@ -49,9 +55,13 @@ export default function ZoneDetailPanel({ const theme = useTheme() const isMobile = useMediaQuery(theme.breakpoints.down('sm')) - if (!zone) return null + const cropLabels = products.length > 0 + ? Object.fromEntries(products.map(p => [p.id, p.label])) + : FALLBACK_LABELS - const chartData = zone.criteria.map(c => ({ subject: c.name, value: c.value, fullMark: 100 })) + if (!open) return null + + const chartData = zone?.criteria?.map(c => ({ subject: c.name, value: c.value, fullMark: 100 })) ?? [] return ( - - - محصول پیشنهادی - - - {CROP_LABELS[zone.crop] ?? zone.crop} - - - درصد تطابق: {zone.matchPercent}% - - نیاز آب: {zone.waterNeed} - سود تخمینی: {zone.estimatedProfit} - + {loading ? ( + + + + ) : zone ? ( + <> + + + محصول پیشنهادی + + + {cropLabels[zone.crop] ?? zone.crop} + + + درصد تطابق: {zone.matchPercent}% + + نیاز آب: {zone.waterNeed} + سود تخمینی: {zone.estimatedProfit} + - - - {t('panel.reason')} - - {zone.reason} - + + + {t('panel.reason')} + + {zone.reason} + - - - {t('panel.criteriaChart')} - -
- - - - - - - - -
-
+ {chartData.length > 0 && ( + + + {t('panel.criteriaChart')} + +
+ + + + + + + + +
+
+ )} - - {t('panel.changeCrop')} - - + + {t('panel.changeCrop')} + + + + ) : ( + + اطلاعاتی یافت نشد. + + )}