UPDATE
This commit is contained in:
@@ -13,6 +13,9 @@ import type {
|
|||||||
import { normalizeRecommendationTaskStatus } from "./recommendationTask";
|
import { normalizeRecommendationTaskStatus } from "./recommendationTask";
|
||||||
|
|
||||||
const PREFIX = "/api/crop-zoning";
|
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 {
|
export interface Product {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -22,12 +25,36 @@ export interface Product {
|
|||||||
|
|
||||||
export interface ZoneInitialData {
|
export interface ZoneInitialData {
|
||||||
zoneId: string;
|
zoneId: string;
|
||||||
|
zoneUuid?: string;
|
||||||
geometry: Polygon;
|
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 باشد، زون غیرقابل کشت و خاکستری نمایش داده میشود */
|
/** اگر null/خالی/uncultivable باشد، زون غیرقابل کشت و خاکستری نمایش داده میشود */
|
||||||
crop?: string | null;
|
crop?: string | null;
|
||||||
matchPercent?: number | null;
|
matchPercent?: number | null;
|
||||||
waterNeed?: string | null;
|
waterNeed?: string | null;
|
||||||
estimatedProfit?: 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 {
|
export interface ZonesInitialResponse {
|
||||||
@@ -73,6 +100,15 @@ export interface CropZoningAreaResult extends AreaResponse {
|
|||||||
status?: string;
|
status?: string;
|
||||||
task?: CropZoningAreaTask | null;
|
task?: CropZoningAreaTask | null;
|
||||||
zones?: ZoneInitialData[];
|
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 =
|
export type CropZoningAreaResponse =
|
||||||
@@ -131,6 +167,11 @@ interface ApiResponse<T> {
|
|||||||
data: T;
|
data: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CachedAreaEntry {
|
||||||
|
expiresAt: number;
|
||||||
|
value: CropZoningAreaResult;
|
||||||
|
}
|
||||||
|
|
||||||
async function unwrap<T>(promise: Promise<ApiResponse<T>>): Promise<T> {
|
async function unwrap<T>(promise: Promise<ApiResponse<T>>): Promise<T> {
|
||||||
const res = await promise;
|
const res = await promise;
|
||||||
return res.data;
|
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 = {
|
export const cropZoningService = {
|
||||||
getProducts(): Promise<{ products: Product[] }> {
|
getProducts(): Promise<{ products: Product[] }> {
|
||||||
return unwrap(
|
return unwrap(
|
||||||
@@ -188,14 +310,51 @@ export const cropZoningService = {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
getArea(sensorUuid: string): Promise<CropZoningAreaResponse> {
|
getArea(
|
||||||
|
sensorUuid: string,
|
||||||
|
options?: { page?: number; pageSize?: number; useCache?: boolean },
|
||||||
|
): Promise<CropZoningAreaResponse> {
|
||||||
|
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(
|
return unwrap(
|
||||||
apiClient.get<ApiResponse<CropZoningAreaResponse>>(`${PREFIX}/area/?sensor_uuid=${sensorUuid}`),
|
apiClient.get<ApiResponse<CropZoningAreaResponse>>(
|
||||||
).then((response) =>
|
`${PREFIX}/area/?${params.toString()}`,
|
||||||
"task_id" in response
|
),
|
||||||
? normalizeTaskInitResponse(response)
|
).then((response) => {
|
||||||
: normalizeAreaResult(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(
|
getAreaStatus(
|
||||||
|
|||||||
@@ -33,8 +33,14 @@ const MapComponent = dynamic(() => Promise.resolve(CropZoningMap), {
|
|||||||
|
|
||||||
const POLL_INTERVAL = 2000;
|
const POLL_INTERVAL = 2000;
|
||||||
const MAX_POLLS = 100;
|
const MAX_POLLS = 100;
|
||||||
|
const ZONES_PAGE_SIZE = 100;
|
||||||
const getNormalizedTaskStatus = (status?: string) => status?.toLowerCase();
|
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() {
|
export default function CropZoningWrapper() {
|
||||||
const t = useTranslations("cropZoning");
|
const t = useTranslations("cropZoning");
|
||||||
const { sensorHub } = useSensorHub();
|
const { sensorHub } = useSensorHub();
|
||||||
@@ -77,36 +83,95 @@ export default function CropZoningWrapper() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
let polls = 0;
|
let polls = 0;
|
||||||
|
|
||||||
|
const loadAllZonePages = async (
|
||||||
|
firstResponse: Extract<
|
||||||
|
Awaited<ReturnType<typeof cropZoningService.getArea>>,
|
||||||
|
{ 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) {
|
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;
|
if (!("area" in res)) break;
|
||||||
|
|
||||||
const task = res.task;
|
const task = res.task;
|
||||||
const taskStatus = getNormalizedTaskStatus(task?.status);
|
const taskStatus = getNormalizedTaskStatus(task?.status);
|
||||||
|
|
||||||
if (task) {
|
if (task) {
|
||||||
setProgress({
|
setProgress({
|
||||||
message: task.message || task.stage_label || t("loadingArea"),
|
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") {
|
if (taskStatus === "completed" || taskStatus === "success") {
|
||||||
setAreaGeoJson(res.area as unknown as MapDrawGeoJSON);
|
await loadAllZonePages(
|
||||||
if (res.zones) setZonesData(res.zones as ZoneInitialData[]);
|
res,
|
||||||
|
task?.message || task?.stage_label || t("loadingArea"),
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((!task && res.area) || (res.area && taskStatus !== "pending" && taskStatus !== "processing")) {
|
if ((!task && res.area) || (res.area && taskStatus !== "pending" && taskStatus !== "processing")) {
|
||||||
setAreaGeoJson(res.area as unknown as MapDrawGeoJSON);
|
await loadAllZonePages(res, task?.message || task?.stage_label || t("loadingArea"));
|
||||||
if (res.zones) setZonesData(res.zones as ZoneInitialData[]);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (taskStatus === "failed" || taskStatus === "failure") {
|
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") {
|
if (taskStatus === "pending" || taskStatus === "processing") {
|
||||||
@@ -116,7 +181,7 @@ export default function CropZoningWrapper() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (polls >= MAX_POLLS) {
|
if (polls >= MAX_POLLS) {
|
||||||
throw new Error(t("errors.timeout"));
|
throw new Error(t("errors.timeout"));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user