Refactor CropZoningMap and related components for improved API integration and UI enhancements
- Updated CropZoningMap to utilize new ZoneInitialData type for zone click handling and added zonesData prop for API-driven zone rendering. - Removed deprecated crop zoning mock data file and integrated grid creation logic for initial zone fetching. - Enhanced CropZoningWrapper to manage area and zone data loading states, improving user experience with asynchronous data fetching. - Updated ZoneDetailPanel to handle loading states and display product labels dynamically based on fetched data. - Refactored ZoneLegend to conditionally render items based on available product data, enhancing visual feedback during loading.
This commit is contained in:
@@ -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<CropType, string> = {
|
||||
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 نمایش داده شود.
|
||||
@@ -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]]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Polygon>
|
||||
}
|
||||
|
||||
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<T> {
|
||||
status: string
|
||||
data: T
|
||||
}
|
||||
|
||||
async function unwrap<T>(promise: Promise<ApiResponse<T>>): Promise<T> {
|
||||
const res = await promise
|
||||
return res.data
|
||||
}
|
||||
|
||||
export const cropZoningService = {
|
||||
getProducts(): Promise<{ products: Product[] }> {
|
||||
return unwrap(apiClient.get<ApiResponse<{ products: Product[] }>>(`${PREFIX}/products/`))
|
||||
},
|
||||
|
||||
getZonesInitial(body: {
|
||||
zones: FeatureCollection<Polygon>
|
||||
products?: string[]
|
||||
}): Promise<ZonesInitialResponse> {
|
||||
return unwrap(apiClient.post<ApiResponse<ZonesInitialResponse>>(`${PREFIX}/zones/initial/`, body))
|
||||
},
|
||||
|
||||
getZoneDetails(zoneId: string): Promise<ZoneDetailData> {
|
||||
return unwrap(apiClient.get<ApiResponse<ZoneDetailData>>(`${PREFIX}/zones/${zoneId}/details/`))
|
||||
},
|
||||
|
||||
getArea(): Promise<AreaResponse> {
|
||||
return unwrap(apiClient.get<ApiResponse<AreaResponse>>(`${PREFIX}/area/`))
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> | 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<string, string>
|
||||
/** غیرفعال کردن رسم و ویرایش منطقه توسط کاربر */
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_PRODUCT_LABELS: Record<string, string> = {
|
||||
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<HTMLDivElement>(null)
|
||||
@@ -43,14 +55,30 @@ export default function CropZoningMap({
|
||||
const drawControlRef = useRef<L.Control.Draw | null>(null)
|
||||
const zonesLayerRef = useRef<L.GeoJSON | null>(null)
|
||||
|
||||
const renderZones = useCallback(
|
||||
(map: L.Map, polygonFeature: Feature<Polygon>) => {
|
||||
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 = `
|
||||
<div style="font-family: inherit; font-size: 12px; padding: 4px 8px; min-width: 160px;">
|
||||
<div style="font-weight: 600; margin-bottom: 6px;">${cropLabel}</div>
|
||||
@@ -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<Polygon>)
|
||||
} 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<Polygon>).eachLayer((layer) => drawnItems.addLayer(layer))
|
||||
emitAndRender()
|
||||
L.geoJSON(initialAreaGeoJson as unknown as Feature<Polygon>).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<Polygon>
|
||||
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
|
||||
|
||||
@@ -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<MapDrawGeoJSON>(MOCK_AREA_GEOJSON)
|
||||
const [areaGeoJson, setAreaGeoJson] = useState<MapDrawGeoJSON>(null)
|
||||
const [areaLoading, setAreaLoading] = useState(true)
|
||||
const [zonesData, setZonesData] = useState<ZoneInitialData[] | null>(null)
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
const [productsLoading, setProductsLoading] = useState(true)
|
||||
const [zonesLoading, setZonesLoading] = useState(false)
|
||||
const [activeLayer, setActiveLayer] = useState<LayerType>('crops')
|
||||
const [selectedZone, setSelectedZone] = useState<ZoneFeatureProperties | null>(null)
|
||||
const [selectedZone, setSelectedZone] = useState<ZoneDetailData | null>(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<import('geojson').Polygon>)
|
||||
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() {
|
||||
<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]'
|
||||
initialAreaGeoJson={MOCK_AREA_GEOJSON}
|
||||
readOnly
|
||||
/>
|
||||
</Box>
|
||||
{areaGeoJson ? (
|
||||
<MapComponent
|
||||
key='crop-zoning-map'
|
||||
center={[35.6892, 51.389]}
|
||||
zoom={13}
|
||||
height='100%'
|
||||
activeLayer={activeLayer}
|
||||
onAreaChange={handleAreaChange}
|
||||
onZoneClick={handleZoneClick}
|
||||
optimizationKey={optimizationKey}
|
||||
className='min-bs-[400px]'
|
||||
initialAreaGeoJson={areaGeoJson}
|
||||
zonesData={zonesData}
|
||||
productLabels={productLabels}
|
||||
readOnly
|
||||
/>
|
||||
) : (
|
||||
<Box className='flex items-center justify-center bs-full min-bs-[400px] rounded-xl bg-action-hover' />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<LayerControl activeLayer={activeLayer} onLayerChange={setActiveLayer} />
|
||||
{(areaLoading || zonesLoading) && (
|
||||
<Box
|
||||
className='absolute inset-0 z-[500] flex items-center justify-center bg-white/60 dark:bg-gray-900/60'
|
||||
sx={{ borderRadius: 12 }}
|
||||
>
|
||||
<CircularProgress size={48} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{areaGeoJson && (
|
||||
<>
|
||||
<ZoneLegend />
|
||||
<LayerControl activeLayer={activeLayer} onLayerChange={setActiveLayer} />
|
||||
|
||||
<ZoneLegend products={products} loading={productsLoading} />
|
||||
|
||||
{areaGeoJson && (
|
||||
<Box className='absolute top-16 end-4 z-[1000]'>
|
||||
<Button
|
||||
variant='contained'
|
||||
color='primary'
|
||||
size='medium'
|
||||
startIcon={<i className='tabler-refresh text-xl' />}
|
||||
onClick={handleOptimize}
|
||||
className='rounded-xl shadow-lg'
|
||||
variant='contained'
|
||||
color='primary'
|
||||
size='medium'
|
||||
startIcon={<i className='tabler-refresh text-xl' />}
|
||||
onClick={handleOptimize}
|
||||
disabled={zonesLoading}
|
||||
className='rounded-xl shadow-lg'
|
||||
>
|
||||
{t('optimizeAgain')}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<ZoneDetailPanel
|
||||
open={panelOpen}
|
||||
onClose={() => setPanelOpen(false)}
|
||||
zone={selectedZone}
|
||||
products={products}
|
||||
loading={zoneDetailLoading}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<CropZoningWeatherSection />
|
||||
</Box>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
const FALLBACK_LABELS: Record<string, string> = {
|
||||
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 (
|
||||
<Drawer
|
||||
@@ -78,62 +88,84 @@ export default function ZoneDetailPanel({
|
||||
</Box>
|
||||
<PerfectScrollbar options={{ wheelPropagation: false }} className='flex-1 px-6 pb-6'>
|
||||
<Box className='flex flex-col gap-4'>
|
||||
<Box className='rounded-xl border border-gray-200/60 dark:border-gray-700/60 p-4 bg-white/50 dark:bg-gray-800/30'>
|
||||
<Typography variant='subtitle2' color='text.secondary' className='mbe-2'>
|
||||
محصول پیشنهادی
|
||||
</Typography>
|
||||
<Typography variant='h6' sx={{ color: CROP_COLORS[zone.crop as keyof typeof CROP_COLORS] }}>
|
||||
{CROP_LABELS[zone.crop] ?? zone.crop}
|
||||
</Typography>
|
||||
<Typography variant='body2' className='mt-2'>
|
||||
درصد تطابق: {zone.matchPercent}%
|
||||
</Typography>
|
||||
<Typography variant='body2'>نیاز آب: {zone.waterNeed}</Typography>
|
||||
<Typography variant='body2'>سود تخمینی: {zone.estimatedProfit}</Typography>
|
||||
</Box>
|
||||
{loading ? (
|
||||
<Box className='flex justify-center py-12'>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : zone ? (
|
||||
<>
|
||||
<Box className='rounded-xl border border-gray-200/60 dark:border-gray-700/60 p-4 bg-white/50 dark:bg-gray-800/30'>
|
||||
<Typography variant='subtitle2' color='text.secondary' className='mbe-2'>
|
||||
محصول پیشنهادی
|
||||
</Typography>
|
||||
<Typography variant='h6' sx={{ color: CROP_COLORS[zone.crop as keyof typeof CROP_COLORS] ?? undefined }}>
|
||||
{cropLabels[zone.crop] ?? zone.crop}
|
||||
</Typography>
|
||||
<Typography variant='body2' className='mt-2'>
|
||||
درصد تطابق: {zone.matchPercent}%
|
||||
</Typography>
|
||||
<Typography variant='body2'>نیاز آب: {zone.waterNeed}</Typography>
|
||||
<Typography variant='body2'>سود تخمینی: {zone.estimatedProfit}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant='subtitle2' color='text.secondary' className='mbe-2'>
|
||||
{t('panel.reason')}
|
||||
</Typography>
|
||||
<Typography variant='body2'>{zone.reason}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant='subtitle2' color='text.secondary' className='mbe-2'>
|
||||
{t('panel.reason')}
|
||||
</Typography>
|
||||
<Typography variant='body2'>{zone.reason}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box className='rounded-xl border border-gray-200/60 dark:border-gray-700/60 p-4 bg-white/50 dark:bg-gray-800/30'>
|
||||
<Typography variant='subtitle2' color='text.secondary' className='mbe-3'>
|
||||
{t('panel.criteriaChart')}
|
||||
</Typography>
|
||||
<div className='bs-[220px]'>
|
||||
<ResponsiveContainer width='100%' height='100%'>
|
||||
<RadarChart data={chartData}>
|
||||
<PolarGrid />
|
||||
<PolarAngleAxis dataKey='subject' />
|
||||
<PolarRadiusAxis angle={90} domain={[0, 100]} />
|
||||
<Radar
|
||||
name='امتیاز'
|
||||
dataKey='value'
|
||||
stroke='var(--mui-palette-primary-main)'
|
||||
fill='var(--mui-palette-primary-main)'
|
||||
fillOpacity={0.4}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Box>
|
||||
{chartData.length > 0 && (
|
||||
<Box className='rounded-xl border border-gray-200/60 dark:border-gray-700/60 p-4 bg-white/50 dark:bg-gray-800/30'>
|
||||
<Typography variant='subtitle2' color='text.secondary' className='mbe-3'>
|
||||
{t('panel.criteriaChart')}
|
||||
</Typography>
|
||||
<div className='bs-[220px]'>
|
||||
<ResponsiveContainer width='100%' height='100%'>
|
||||
<RadarChart data={chartData}>
|
||||
<PolarGrid />
|
||||
<PolarAngleAxis dataKey='subject' />
|
||||
<PolarRadiusAxis angle={90} domain={[0, 100]} />
|
||||
<Radar
|
||||
name='امتیاز'
|
||||
dataKey='value'
|
||||
stroke='var(--mui-palette-primary-main)'
|
||||
fill='var(--mui-palette-primary-main)'
|
||||
fillOpacity={0.4}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<FormControl fullWidth size='small'>
|
||||
<InputLabel>{t('panel.changeCrop')}</InputLabel>
|
||||
<Select
|
||||
value={zone.crop}
|
||||
label={t('panel.changeCrop')}
|
||||
onChange={e => onCropChange?.(zone.zoneId, e.target.value)}
|
||||
>
|
||||
<MenuItem value='wheat'>{CROP_LABELS.wheat}</MenuItem>
|
||||
<MenuItem value='canola'>{CROP_LABELS.canola}</MenuItem>
|
||||
<MenuItem value='saffron'>{CROP_LABELS.saffron}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth size='small'>
|
||||
<InputLabel>{t('panel.changeCrop')}</InputLabel>
|
||||
<Select
|
||||
value={zone.crop}
|
||||
label={t('panel.changeCrop')}
|
||||
onChange={e => onCropChange?.(zone.zoneId, e.target.value)}
|
||||
>
|
||||
{products.length > 0
|
||||
? products.map(p => (
|
||||
<MenuItem key={p.id} value={p.id}>
|
||||
{p.label}
|
||||
</MenuItem>
|
||||
))
|
||||
: Object.entries(FALLBACK_LABELS).map(([id, label]) => (
|
||||
<MenuItem key={id} value={id}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</>
|
||||
) : (
|
||||
<Typography variant='body2' color='text.secondary'>
|
||||
اطلاعاتی یافت نشد.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Button variant='outlined' onClick={onClose} fullWidth>
|
||||
{tCommon('close')}
|
||||
|
||||
@@ -2,30 +2,43 @@
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { CROP_COLORS, type CropType } from './cropZoningTypes'
|
||||
import type { Product } from '@/libs/api/services/cropZoningService'
|
||||
|
||||
export default function ZoneLegend() {
|
||||
const FALLBACK_ITEMS: { crop: CropType; label: string }[] = [
|
||||
{ crop: 'wheat', label: 'گندم' },
|
||||
{ crop: 'canola', label: 'کلزا' },
|
||||
{ crop: 'saffron', label: 'زعفران' }
|
||||
]
|
||||
|
||||
type ZoneLegendProps = {
|
||||
products?: Product[]
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export default function ZoneLegend({ products = [], loading = false }: ZoneLegendProps) {
|
||||
const t = useTranslations('cropZoning')
|
||||
|
||||
const items: { crop: CropType; label: string }[] = [
|
||||
{ crop: 'wheat', label: t('crops.wheat') },
|
||||
{ crop: 'canola', label: t('crops.canola') },
|
||||
{ crop: 'saffron', label: t('crops.saffron') }
|
||||
]
|
||||
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] }))
|
||||
|
||||
return (
|
||||
<div className='absolute bottom-4 start-4 z-[1000] rounded-xl border border-white/20 bg-white/70 px-4 py-3 shadow-lg backdrop-blur-md dark:bg-gray-900/70 dark:border-gray-700/50'>
|
||||
<div className='text-xs font-medium text-gray-600 dark:text-gray-400 mbe-2'>{t('legend')}</div>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
{items.map(({ crop, label }) => (
|
||||
<div key={crop} className='flex items-center gap-2'>
|
||||
<div
|
||||
className='h-4 w-4 rounded'
|
||||
style={{ backgroundColor: CROP_COLORS[crop], opacity: 0.8 }}
|
||||
/>
|
||||
<span className='text-sm text-gray-700 dark:text-gray-300'>{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className='text-sm text-gray-500'>...</div>
|
||||
) : (
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
{items.map(({ crop, label, color }) => (
|
||||
<div key={crop} className='flex items-center gap-2'>
|
||||
<div
|
||||
className='h-4 w-4 rounded'
|
||||
style={{ backgroundColor: color ?? CROP_COLORS[crop as CropType] ?? '#94a3b8', opacity: 0.8 }}
|
||||
/>
|
||||
<span className='text-sm text-gray-700 dark:text-gray-300'>{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { MapDrawGeoJSON } from './CropZoningMap'
|
||||
|
||||
/**
|
||||
* منطقهٔ ثابت برای نمایش روی نقشه (دیتای ماک).
|
||||
* کاربر امکان تغییر یا رسم منطقهٔ جدید را ندارد.
|
||||
* مختصات: یک چندضلعی حول تهران [35.6892, 51.389] — در GeoJSON به صورت [lng, lat].
|
||||
*/
|
||||
export const MOCK_AREA_GEOJSON: MapDrawGeoJSON = {
|
||||
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]
|
||||
]
|
||||
]
|
||||
}
|
||||
} as MapDrawGeoJSON
|
||||
@@ -78,3 +78,25 @@ export function createZonedGrid(
|
||||
features
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates grid geometry only (no crop assignment) for sending to POST /api/crop-zoning/zones/initial/
|
||||
*/
|
||||
export function createGridFromPolygon(
|
||||
polygonFeature: Feature<Polygon>,
|
||||
cellSideKm = 0.15
|
||||
): FeatureCollection<Polygon, { index?: number }> {
|
||||
const bboxArr = bbox(polygonFeature)
|
||||
const grid = squareGrid(bboxArr, cellSideKm, {
|
||||
units: 'kilometers',
|
||||
mask: polygonFeature
|
||||
})
|
||||
const features = grid.features.map((f, i) => ({
|
||||
...f,
|
||||
properties: { index: i }
|
||||
}))
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user