diff --git a/src/libs/api/services/cropZoningService.ts b/src/libs/api/services/cropZoningService.ts index 84c33cd..ef627e9 100644 --- a/src/libs/api/services/cropZoningService.ts +++ b/src/libs/api/services/cropZoningService.ts @@ -13,6 +13,9 @@ import type { import { normalizeRecommendationTaskStatus } from "./recommendationTask"; const PREFIX = "/api/crop-zoning"; +const AREA_CACHE_KEY_PREFIX = "crop-zoning:area"; +const AREA_CACHE_VERSION = "v1"; +const AREA_CACHE_TTL_MS = 1000 * 60 * 60 * 6; export interface Product { id: string; @@ -22,12 +25,36 @@ export interface Product { export interface ZoneInitialData { zoneId: string; + zoneUuid?: string; geometry: Polygon; + center?: { + latitude: number; + longitude: number; + }; + area_sqm?: number; + area_hectares?: number; + sequence?: number; + processing_status?: "pending" | "processing" | "completed" | "failed"; + processing_error?: string; /** اگر null/خالی/uncultivable باشد، زون غیرقابل کشت و خاکستری نمایش داده می‌شود */ crop?: string | null; matchPercent?: number | null; waterNeed?: string | null; estimatedProfit?: string | null; + waterNeedLayer?: { + level: "low" | "medium" | "high"; + value?: string; + color: string; + }; + soilQualityLayer?: { + level: "low" | "medium" | "high"; + score?: number; + color: string; + }; + cultivationRiskLayer?: { + level: "low" | "medium" | "high"; + color: string; + }; } export interface ZonesInitialResponse { @@ -73,6 +100,15 @@ export interface CropZoningAreaResult extends AreaResponse { status?: string; task?: CropZoningAreaTask | null; zones?: ZoneInitialData[]; + pagination?: { + page: number; + page_size: number; + total_pages: number; + total_zones: number; + returned_zones: number; + has_next: boolean; + has_previous: boolean; + }; } export type CropZoningAreaResponse = @@ -131,6 +167,11 @@ interface ApiResponse { data: T; } +interface CachedAreaEntry { + expiresAt: number; + value: CropZoningAreaResult; +} + async function unwrap(promise: Promise>): Promise { const res = await promise; return res.data; @@ -159,6 +200,87 @@ function normalizeAreaResult( }; } +function getAreaCacheUserKey(): string { + if (typeof window === "undefined") return "server"; + + const token = localStorage.getItem("auth_token"); + + if (!token) { + return "guest"; + } + + try { + return btoa(token).slice(0, 24); + } catch { + return token.slice(0, 24); + } +} + +function getAreaCacheKey( + sensorUuid: string, + page: number, + pageSize: number, +): string { + return [ + AREA_CACHE_KEY_PREFIX, + AREA_CACHE_VERSION, + getAreaCacheUserKey(), + sensorUuid, + page, + pageSize, + ].join(":"); +} + +function readCachedArea( + sensorUuid: string, + page: number, + pageSize: number, +): CropZoningAreaResult | null { + if (typeof window === "undefined") return null; + + try { + const raw = localStorage.getItem(getAreaCacheKey(sensorUuid, page, pageSize)); + + if (!raw) { + return null; + } + + const parsed = JSON.parse(raw) as CachedAreaEntry; + + if (!parsed?.expiresAt || parsed.expiresAt < Date.now()) { + localStorage.removeItem(getAreaCacheKey(sensorUuid, page, pageSize)); + return null; + } + + return normalizeAreaResult(parsed.value); + } catch { + return null; + } +} + +function writeCachedArea( + sensorUuid: string, + page: number, + pageSize: number, + value: CropZoningAreaResult, +): void { + if (typeof window === "undefined") return; + + try { + const payload: CachedAreaEntry = { + expiresAt: Date.now() + AREA_CACHE_TTL_MS, + value, + }; + + localStorage.setItem( + getAreaCacheKey(sensorUuid, page, pageSize), + JSON.stringify(payload), + ); + } catch { + // Ignore storage quota and serialization errors. + } +} + export const cropZoningService = { getProducts(): Promise<{ products: Product[] }> { return unwrap( @@ -188,14 +310,51 @@ export const cropZoningService = { ); }, - getArea(sensorUuid: string): Promise { + getArea( + sensorUuid: string, + options?: { page?: number; pageSize?: number; useCache?: boolean }, + ): Promise { + const page = options?.page ?? 1; + const pageSize = options?.pageSize ?? 10; + const useCache = options?.useCache ?? true; + + if (useCache) { + const cached = readCachedArea(sensorUuid, page, pageSize); + + if (cached) { + return Promise.resolve(cached); + } + } + + const params = new URLSearchParams({ sensor_uuid: sensorUuid }); + + params.set("page", String(page)); + params.set("page_size", String(pageSize)); + return unwrap( - apiClient.get>(`${PREFIX}/area/?sensor_uuid=${sensorUuid}`), - ).then((response) => - "task_id" in response - ? normalizeTaskInitResponse(response) - : normalizeAreaResult(response), - ); + apiClient.get>( + `${PREFIX}/area/?${params.toString()}`, + ), + ).then((response) => { + if ("task_id" in response) { + return normalizeTaskInitResponse(response); + } + + const normalized = normalizeAreaResult(response); + const taskStatus = normalized.task?.status?.toLowerCase(); + + if ( + normalized.area && + taskStatus !== "pending" && + taskStatus !== "processing" && + taskStatus !== "failure" && + taskStatus !== "failed" + ) { + writeCachedArea(sensorUuid, page, pageSize, normalized); + } + + return normalized; + }); }, getAreaStatus( diff --git a/src/views/dashboards/farm/cropZoning/CropZoningWrapper.tsx b/src/views/dashboards/farm/cropZoning/CropZoningWrapper.tsx index b082d73..83b29ab 100644 --- a/src/views/dashboards/farm/cropZoning/CropZoningWrapper.tsx +++ b/src/views/dashboards/farm/cropZoning/CropZoningWrapper.tsx @@ -33,8 +33,14 @@ const MapComponent = dynamic(() => Promise.resolve(CropZoningMap), { const POLL_INTERVAL = 2000; const MAX_POLLS = 100; +const ZONES_PAGE_SIZE = 100; const getNormalizedTaskStatus = (status?: string) => status?.toLowerCase(); +const mergeZones = (pages: ZoneInitialData[][]) => + pages + .flat() + .sort((left, right) => (left.sequence ?? 0) - (right.sequence ?? 0)); + export default function CropZoningWrapper() { const t = useTranslations("cropZoning"); const { sensorHub } = useSensorHub(); @@ -77,36 +83,95 @@ export default function CropZoningWrapper() { try { let polls = 0; - + + const loadAllZonePages = async ( + firstResponse: Extract< + Awaited>, + { area: unknown } + >, + completedTaskMessage?: string, + ) => { + setAreaGeoJson(firstResponse.area as unknown as MapDrawGeoJSON); + + const firstPageZones = firstResponse.zones ?? []; + const totalPages = Math.max(firstResponse.pagination?.total_pages ?? 1, 1); + const pageSize = firstResponse.pagination?.page_size ?? ZONES_PAGE_SIZE; + const zonePages: ZoneInitialData[][] = [firstPageZones]; + + setZonesData(firstPageZones as ZoneInitialData[]); + setProgress({ + message: totalPages > 1 ? `${t("loadingArea")} (1/${totalPages})` : completedTaskMessage || t("loadingArea"), + percent: totalPages > 1 ? 80 : 100, + }); + + for (let page = 2; page <= totalPages; page++) { + if (cancelled) break; + + const pageRes = await cropZoningService.getArea(sensorHub.id, { + page, + pageSize, + }); + + if (!("area" in pageRes)) { + throw new Error(t("errors.areaLoadFailed")); + } + + zonePages.push(pageRes.zones ?? []); + + const mergedZones = mergeZones(zonePages); + const pagesLoaded = zonePages.length; + const pagesProgress = + totalPages > 0 ? Math.round((pagesLoaded / totalPages) * 20) : 20; + + setZonesData(mergedZones); + setProgress({ + message: `${t("loadingArea")} (${pagesLoaded}/${totalPages})`, + percent: Math.min(80 + pagesProgress, 100), + }); + } + + if (!cancelled) { + setZonesData(mergeZones(zonePages)); + setProgress({ + message: completedTaskMessage || t("loadingArea"), + percent: 100, + }); + } + }; + while (!cancelled && polls < MAX_POLLS) { - const res = await cropZoningService.getArea(sensorHub.id); + const res = await cropZoningService.getArea(sensorHub.id, { + page: 1, + pageSize: ZONES_PAGE_SIZE, + }); if (!("area" in res)) break; const task = res.task; const taskStatus = getNormalizedTaskStatus(task?.status); - + if (task) { setProgress({ message: task.message || task.stage_label || t("loadingArea"), - percent: task.progress_percent || 0, + percent: Math.min(Math.round((task.progress_percent || 0) * 0.8), 80), }); } if (taskStatus === "completed" || taskStatus === "success") { - setAreaGeoJson(res.area as unknown as MapDrawGeoJSON); - if (res.zones) setZonesData(res.zones as ZoneInitialData[]); + await loadAllZonePages( + res, + task?.message || task?.stage_label || t("loadingArea"), + ); break; } if ((!task && res.area) || (res.area && taskStatus !== "pending" && taskStatus !== "processing")) { - setAreaGeoJson(res.area as unknown as MapDrawGeoJSON); - if (res.zones) setZonesData(res.zones as ZoneInitialData[]); + await loadAllZonePages(res, task?.message || task?.stage_label || t("loadingArea")); break; } if (taskStatus === "failed" || taskStatus === "failure") { - throw new Error(task.message || t("errors.areaLoadFailed")); + throw new Error(task?.message || t("errors.areaLoadFailed")); } if (taskStatus === "pending" || taskStatus === "processing") { @@ -116,7 +181,7 @@ export default function CropZoningWrapper() { break; } } - + if (polls >= MAX_POLLS) { throw new Error(t("errors.timeout")); }