Files
Frontend/src/views/dashboards/farm/cropZoning/CropZoningMap.tsx
T

301 lines
9.8 KiB
TypeScript
Raw Normal View History

'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'
import type { ZoneInitialData } from '@/libs/api/services/cropZoningService'
export type MapDrawGeoJSON = Record<string, unknown> | null
type CropZoningMapProps = {
center?: [number, number]
zoom?: number
height?: string | number
activeLayer: LayerType
onAreaChange?: (geojson: MapDrawGeoJSON) => 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,
height = '100%',
activeLayer,
onAreaChange,
onZoneClick,
optimizationKey = 0,
className = '',
initialAreaGeoJson = null,
zonesData = null,
productLabels = DEFAULT_PRODUCT_LABELS,
readOnly = false
}: 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)
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 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 {
fillColor: (CROP_COLORS[props.crop as CropType] ?? '#94a3b8'),
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
if (!props || !geom) return
const layer = leafLayer as L.Polygon
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>
<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]
})
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))
}
})
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)
})
},
[activeLayer, onZoneClick, productLabels]
)
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
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
}
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
}
const emitAreaChange = () => {
const geojson = getGeoJsonFromDrawn()
onAreaChange?.(geojson)
if (!geojson && zonesLayerRef.current) {
map.removeLayer(zonesLayerRef.current)
zonesLayerRef.current = null
}
}
if (readOnly && initialAreaGeoJson && initialAreaGeoJson.geometry && (initialAreaGeoJson.geometry as { type: string }).type === 'Polygon') {
drawnItems.clearLayers()
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)
emitAreaChange()
}
const onEdited = () => emitAreaChange()
const onDeleted = () => {
onAreaChange?.(null)
if (zonesLayerRef.current) {
map.removeLayer(zonesLayerRef.current)
zonesLayerRef.current = null
}
}
if (!readOnly) {
map.on(L.Draw.Event.CREATED, onCreated)
map.on(L.Draw.Event.EDITED, onEdited)
map.on(L.Draw.Event.DELETED, onDeleted)
}
mapInstanceRef.current = map
cleanupFn = () => {
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)
}
}
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(() => {
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
}
}, [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 (
<div
ref={mapRef}
style={{
height: typeof height === 'number' ? `${height}px` : height,
width: '100%',
borderRadius: 12
}}
className={`overflow-hidden ${className}`}
/>
)
}