2026-02-20 22:15:34 +03:30
|
|
|
'use client'
|
|
|
|
|
|
|
|
|
|
import { useEffect, useRef, useCallback } from 'react'
|
|
|
|
|
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'
|
2026-02-26 00:17:32 +03:30
|
|
|
import type { ZoneInitialData } from '@/libs/api/services/cropZoningService'
|
2026-02-20 22:15:34 +03:30
|
|
|
|
|
|
|
|
export type MapDrawGeoJSON = Record<string, unknown> | null
|
|
|
|
|
|
|
|
|
|
type CropZoningMapProps = {
|
|
|
|
|
center?: [number, number]
|
|
|
|
|
zoom?: number
|
|
|
|
|
height?: string | number
|
|
|
|
|
activeLayer: LayerType
|
|
|
|
|
onAreaChange?: (geojson: MapDrawGeoJSON) => void
|
2026-02-26 00:17:32 +03:30
|
|
|
onZoneClick?: (zone: ZoneInitialData) => void
|
2026-02-20 22:15:34 +03:30
|
|
|
optimizationKey?: number
|
|
|
|
|
className?: string
|
2026-02-26 00:17:32 +03:30
|
|
|
/** منطقهٔ اولیه؛ وقتی مقدار دارد و readOnly باشد نقشه فقط نمایشی است */
|
2026-02-21 22:05:47 +03:30
|
|
|
initialAreaGeoJson?: MapDrawGeoJSON | null
|
2026-02-26 00:17:32 +03:30
|
|
|
/** دیتای زونها از API (POST zones/initial) */
|
|
|
|
|
zonesData?: ZoneInitialData[] | null
|
|
|
|
|
/** لیبل محصولات از API (id -> label) برای tooltip */
|
|
|
|
|
productLabels?: Record<string, string>
|
2026-02-21 22:05:47 +03:30
|
|
|
/** غیرفعال کردن رسم و ویرایش منطقه توسط کاربر */
|
|
|
|
|
readOnly?: boolean
|
2026-02-20 22:15:34 +03:30
|
|
|
}
|
|
|
|
|
|
2026-02-26 00:17:32 +03:30
|
|
|
const DEFAULT_PRODUCT_LABELS: Record<string, string> = {
|
|
|
|
|
wheat: 'گندم',
|
|
|
|
|
canola: 'کلزا',
|
|
|
|
|
saffron: 'زعفران'
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 22:15:34 +03:30
|
|
|
export default function CropZoningMap({
|
|
|
|
|
center = [35.6892, 51.389],
|
|
|
|
|
zoom = 13,
|
|
|
|
|
height = '100%',
|
|
|
|
|
activeLayer,
|
|
|
|
|
onAreaChange,
|
|
|
|
|
onZoneClick,
|
|
|
|
|
optimizationKey = 0,
|
2026-02-21 22:05:47 +03:30
|
|
|
className = '',
|
|
|
|
|
initialAreaGeoJson = null,
|
2026-02-26 00:17:32 +03:30
|
|
|
zonesData = null,
|
|
|
|
|
productLabels = DEFAULT_PRODUCT_LABELS,
|
2026-02-21 22:05:47 +03:30
|
|
|
readOnly = false
|
2026-02-20 22:15:34 +03:30
|
|
|
}: CropZoningMapProps) {
|
|
|
|
|
const mapRef = useRef<HTMLDivElement>(null)
|
|
|
|
|
const mapInstanceRef = useRef<L.Map | null>(null)
|
|
|
|
|
const drawnItemsRef = useRef<L.FeatureGroup | null>(null)
|
|
|
|
|
const drawControlRef = useRef<L.Control.Draw | null>(null)
|
|
|
|
|
const zonesLayerRef = useRef<L.GeoJSON | null>(null)
|
|
|
|
|
|
2026-02-26 00:17:32 +03:30
|
|
|
const renderZonesFromApi = useCallback(
|
|
|
|
|
(map: L.Map, zones: ZoneInitialData[]) => {
|
2026-02-20 22:15:34 +03:30
|
|
|
if (zonesLayerRef.current) {
|
|
|
|
|
map.removeLayer(zonesLayerRef.current)
|
|
|
|
|
zonesLayerRef.current = null
|
|
|
|
|
}
|
2026-02-26 00:17:32 +03:30
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
}
|
2026-02-20 22:15:34 +03:30
|
|
|
|
|
|
|
|
const L = (window as unknown as { L: typeof import('leaflet') }).L
|
|
|
|
|
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' }
|
|
|
|
|
}
|
|
|
|
|
return {
|
2026-02-26 00:17:32 +03:30
|
|
|
fillColor: (CROP_COLORS[props.crop as CropType] ?? '#94a3b8'),
|
2026-02-20 22:15:34 +03:30
|
|
|
fillOpacity: 0.5,
|
|
|
|
|
weight: 1,
|
|
|
|
|
color: '#fff'
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-02-26 00:17:32 +03:30
|
|
|
onEachFeature: (feat: { geometry?: unknown; properties?: ZoneFeatureProperties }, leafLayer: L.Layer) => {
|
|
|
|
|
const feature = feat as { geometry: Polygon; properties?: ZoneFeatureProperties }
|
2026-02-20 22:15:34 +03:30
|
|
|
const props = feature?.properties
|
2026-02-26 00:17:32 +03:30
|
|
|
const geom = feature?.geometry
|
|
|
|
|
if (!props || !geom) return
|
2026-02-20 22:15:34 +03:30
|
|
|
const layer = leafLayer as L.Polygon
|
2026-02-26 00:17:32 +03:30
|
|
|
const cropLabel = labels[props.crop] ?? props.crop
|
2026-02-20 22:15:34 +03:30
|
|
|
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>
|
|
|
|
|
<div>درصد تطابق: ${props.matchPercent}%</div>
|
|
|
|
|
<div>نیاز آب: ${props.waterNeed}</div>
|
|
|
|
|
<div>سود تخمینی: ${props.estimatedProfit}</div>
|
|
|
|
|
</div>
|
|
|
|
|
`
|
|
|
|
|
layer.bindTooltip(tooltipContent, {
|
|
|
|
|
sticky: true,
|
|
|
|
|
className: 'zone-tooltip',
|
|
|
|
|
direction: 'top',
|
|
|
|
|
offset: [0, -8]
|
|
|
|
|
})
|
2026-02-26 00:17:32 +03:30
|
|
|
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))
|
2026-02-20 22:15:34 +03:30
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
geoJsonLayer.addTo(map)
|
|
|
|
|
zonesLayerRef.current = geoJsonLayer
|
|
|
|
|
|
|
|
|
|
let idx = 0
|
|
|
|
|
geoJsonLayer.eachLayer((layer: L.Layer) => {
|
|
|
|
|
const leafLayer = layer as L.Polygon
|
|
|
|
|
leafLayer.setStyle({ fillOpacity: 0 })
|
|
|
|
|
const index = idx++
|
|
|
|
|
const targetOpacity = 0.5
|
|
|
|
|
const delay = Math.min(index * 30, 600)
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
leafLayer.setStyle({ fillOpacity: targetOpacity })
|
|
|
|
|
}, delay)
|
|
|
|
|
})
|
|
|
|
|
},
|
2026-02-26 00:17:32 +03:30
|
|
|
[activeLayer, onZoneClick, productLabels]
|
2026-02-20 22:15:34 +03:30
|
|
|
)
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (typeof window === 'undefined' || !mapRef.current) return
|
|
|
|
|
|
|
|
|
|
let isMounted = true
|
|
|
|
|
let cleanupFn: (() => void) | null = null
|
|
|
|
|
|
|
|
|
|
const initMap = async () => {
|
|
|
|
|
const L = (await import('leaflet')).default
|
|
|
|
|
await import('leaflet-draw')
|
|
|
|
|
;(window as unknown as { L: typeof L }).L = L
|
|
|
|
|
|
|
|
|
|
if (!isMounted || !mapRef.current || mapInstanceRef.current) return
|
|
|
|
|
|
|
|
|
|
const map = L.map(mapRef.current).setView(center, zoom)
|
|
|
|
|
|
|
|
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
|
|
|
attribution: '© OpenStreetMap'
|
|
|
|
|
}).addTo(map)
|
|
|
|
|
|
|
|
|
|
const drawnItems = L.featureGroup().addTo(map)
|
|
|
|
|
drawnItemsRef.current = drawnItems
|
|
|
|
|
|
2026-02-21 22:05:47 +03:30
|
|
|
if (!readOnly) {
|
|
|
|
|
const drawControl = new L.Control.Draw({
|
|
|
|
|
position: 'topright',
|
|
|
|
|
draw: {
|
|
|
|
|
polygon: { shapeOptions: { color: '#3388ff' } },
|
|
|
|
|
rectangle: { shapeOptions: { color: '#3388ff' } },
|
|
|
|
|
circle: false,
|
|
|
|
|
circlemarker: false,
|
|
|
|
|
marker: false,
|
|
|
|
|
polyline: false
|
|
|
|
|
},
|
|
|
|
|
edit: { featureGroup: drawnItems, remove: true }
|
|
|
|
|
})
|
|
|
|
|
map.addControl(drawControl)
|
|
|
|
|
drawControlRef.current = drawControl
|
|
|
|
|
}
|
2026-02-20 22:15:34 +03:30
|
|
|
|
|
|
|
|
const getGeoJsonFromDrawn = (): MapDrawGeoJSON => {
|
|
|
|
|
const geojson = drawnItems.toGeoJSON()
|
|
|
|
|
if (geojson.type === 'FeatureCollection' && geojson.features.length > 0) {
|
|
|
|
|
return geojson.features[0] as unknown as MapDrawGeoJSON
|
|
|
|
|
}
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 00:17:32 +03:30
|
|
|
const emitAreaChange = () => {
|
2026-02-20 22:15:34 +03:30
|
|
|
const geojson = getGeoJsonFromDrawn()
|
|
|
|
|
onAreaChange?.(geojson)
|
2026-02-26 00:17:32 +03:30
|
|
|
if (!geojson && zonesLayerRef.current) {
|
2026-02-20 22:15:34 +03:30
|
|
|
map.removeLayer(zonesLayerRef.current)
|
|
|
|
|
zonesLayerRef.current = null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 22:05:47 +03:30
|
|
|
if (readOnly && initialAreaGeoJson && initialAreaGeoJson.geometry && (initialAreaGeoJson.geometry as { type: string }).type === 'Polygon') {
|
|
|
|
|
drawnItems.clearLayers()
|
2026-02-26 00:17:32 +03:30
|
|
|
L.geoJSON(initialAreaGeoJson as unknown as Feature<Polygon>).eachLayer((layer) => drawnItems.addLayer(layer))
|
|
|
|
|
emitAreaChange()
|
2026-02-21 22:05:47 +03:30
|
|
|
}
|
|
|
|
|
|
2026-02-20 22:15:34 +03:30
|
|
|
const onCreated = (e: L.LeafletEvent) => {
|
|
|
|
|
const event = e as L.DrawEvents.Created
|
|
|
|
|
drawnItems.clearLayers()
|
|
|
|
|
drawnItems.addLayer(event.layer)
|
2026-02-26 00:17:32 +03:30
|
|
|
emitAreaChange()
|
2026-02-20 22:15:34 +03:30
|
|
|
}
|
|
|
|
|
|
2026-02-26 00:17:32 +03:30
|
|
|
const onEdited = () => emitAreaChange()
|
2026-02-20 22:15:34 +03:30
|
|
|
const onDeleted = () => {
|
|
|
|
|
onAreaChange?.(null)
|
|
|
|
|
if (zonesLayerRef.current) {
|
|
|
|
|
map.removeLayer(zonesLayerRef.current)
|
|
|
|
|
zonesLayerRef.current = null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 22:05:47 +03:30
|
|
|
if (!readOnly) {
|
|
|
|
|
map.on(L.Draw.Event.CREATED, onCreated)
|
|
|
|
|
map.on(L.Draw.Event.EDITED, onEdited)
|
|
|
|
|
map.on(L.Draw.Event.DELETED, onDeleted)
|
|
|
|
|
}
|
2026-02-20 22:15:34 +03:30
|
|
|
|
|
|
|
|
mapInstanceRef.current = map
|
|
|
|
|
|
|
|
|
|
cleanupFn = () => {
|
2026-02-21 22:05:47 +03:30
|
|
|
if (!readOnly) {
|
|
|
|
|
map.off(L.Draw.Event.CREATED, onCreated)
|
|
|
|
|
map.off(L.Draw.Event.EDITED, onEdited)
|
|
|
|
|
map.off(L.Draw.Event.DELETED, onDeleted)
|
|
|
|
|
if (drawControlRef.current) {
|
|
|
|
|
map.removeControl(drawControlRef.current)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-20 22:15:34 +03:30
|
|
|
if (zonesLayerRef.current) map.removeLayer(zonesLayerRef.current)
|
|
|
|
|
map.remove()
|
|
|
|
|
mapInstanceRef.current = null
|
|
|
|
|
drawnItemsRef.current = null
|
|
|
|
|
drawControlRef.current = null
|
|
|
|
|
zonesLayerRef.current = null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
initMap()
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
isMounted = false
|
|
|
|
|
if (cleanupFn) cleanupFn()
|
|
|
|
|
}
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-02-26 00:17:32 +03:30
|
|
|
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
|
2026-02-20 22:15:34 +03:30
|
|
|
}
|
2026-02-26 00:17:32 +03:30
|
|
|
}, [zonesData, optimizationKey, renderZonesFromApi])
|
2026-02-20 22:15:34 +03:30
|
|
|
|
|
|
|
|
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 (
|
|
|
|
|
<div
|
|
|
|
|
ref={mapRef}
|
|
|
|
|
style={{
|
|
|
|
|
height: typeof height === 'number' ? `${height}px` : height,
|
|
|
|
|
width: '100%',
|
|
|
|
|
borderRadius: 12
|
|
|
|
|
}}
|
|
|
|
|
className={`overflow-hidden ${className}`}
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
}
|