This commit is contained in:
2026-04-01 17:58:42 +03:30
parent bde110868a
commit e76dd01c4d
2 changed files with 241 additions and 17 deletions
+166 -7
View File
@@ -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<T> {
data: T;
}
interface CachedAreaEntry {
expiresAt: number;
value: CropZoningAreaResult;
}
async function unwrap<T>(promise: Promise<ApiResponse<T>>): Promise<T> {
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<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(
apiClient.get<ApiResponse<CropZoningAreaResponse>>(`${PREFIX}/area/?sensor_uuid=${sensorUuid}`),
).then((response) =>
"task_id" in response
? normalizeTaskInitResponse(response)
: normalizeAreaResult(response),
);
apiClient.get<ApiResponse<CropZoningAreaResponse>>(
`${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(
@@ -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();
@@ -78,8 +84,66 @@ export default function CropZoningWrapper() {
try {
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) {
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;
@@ -89,24 +153,25 @@ export default function CropZoningWrapper() {
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") {