gendesign/frontend/src/lib/site-finder-api.ts
lekss361 307a75f333
All checks were successful
Deploy / deploy (push) Successful in 41s
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m44s
feat(sf-fe-a6): Section 2 «Сети и точки подключения» — real B5 utilities data (#354)
2026-05-18 00:27:55 +00:00

393 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Site Finder API hooks — TanStack Query wrappers.
*
* Mock fallback strategy (from mock-toggle.ts):
* MOCK_PARCELS_BBOX — uses parcels-bbox.json fixture (B1 not yet in prod)
* MOCK_RECENT_PARCELS — uses localStorage only (B2 stub not yet in prod)
* MOCK_ANALYZE — uses parcel-analyze.json fixture (B5)
* MOCK_POI_SCORE — uses poi-score.json fixture (B6)
*/
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api";
import {
MOCK_PARCELS_BBOX,
MOCK_RECENT_PARCELS,
MOCK_ANALYZE,
MOCK_POI_SCORE,
} from "@/lib/mock-toggle";
import fixtureParcels from "@/lib/mocks/parcels-bbox.json";
import fixtureAnalyze from "@/lib/mocks/parcel-analyze.json";
import fixturePoiScore from "@/lib/mocks/poi-score.json";
// ── Types ─────────────────────────────────────────────────────────────────────
export type ParcelStatus = "free" | "in_progress" | "favorite";
export type ParcelVri =
| "multistory"
| "mixed"
| "individual"
| "office"
| "other";
export interface ParcelBboxItem {
cad_num: string;
address: string;
area_ha: number;
status: ParcelStatus;
district: string;
vri: ParcelVri;
lat: number;
lon: number;
}
export interface ParcelBboxFilters {
min_area?: number;
max_area?: number;
status?: ParcelStatus;
district?: string;
vri?: ParcelVri;
}
export interface BboxCoords {
minLat: number;
minLon: number;
maxLat: number;
maxLon: number;
}
export interface RecentParcel {
cad_num: string;
address: string;
area_ha: number;
district: string;
visited_at: string; // ISO string
}
// ── Constants ─────────────────────────────────────────────────────────────────
const LOCAL_STORAGE_KEY = "gd_recent_parcels";
const MAX_RECENT = 10;
// ── localStorage helpers ──────────────────────────────────────────────────────
export function getLocalRecentParcels(): RecentParcel[] {
if (typeof window === "undefined") return [];
try {
const raw = localStorage.getItem(LOCAL_STORAGE_KEY);
if (!raw) return [];
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed as RecentParcel[];
} catch {
return [];
}
}
export function addLocalRecentParcel(parcel: RecentParcel): void {
if (typeof window === "undefined") return;
try {
const existing = getLocalRecentParcels();
const filtered = existing.filter((p) => p.cad_num !== parcel.cad_num);
const updated = [parcel, ...filtered].slice(0, MAX_RECENT);
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(updated));
} catch {
// ignore localStorage errors
}
}
// ── Hook: useParcelsBboxQuery ─────────────────────────────────────────────────
function applyFilters(
parcels: ParcelBboxItem[],
filters: ParcelBboxFilters,
): ParcelBboxItem[] {
return parcels.filter((p) => {
if (filters.min_area != null && p.area_ha < filters.min_area) return false;
if (filters.max_area != null && p.area_ha > filters.max_area) return false;
if (filters.status != null && p.status !== filters.status) return false;
if (
filters.district != null &&
filters.district !== "" &&
p.district !== filters.district
)
return false;
if (filters.vri != null && filters.vri !== "other" && p.vri !== filters.vri)
return false;
return true;
});
}
function filterByBbox(
parcels: ParcelBboxItem[],
bbox: BboxCoords,
): ParcelBboxItem[] {
return parcels.filter(
(p) =>
p.lat >= bbox.minLat &&
p.lat <= bbox.maxLat &&
p.lon >= bbox.minLon &&
p.lon <= bbox.maxLon,
);
}
export function useParcelsBboxQuery(
bbox: BboxCoords | null,
filters: ParcelBboxFilters = {},
) {
return useQuery({
queryKey: [
"parcels-bbox",
bbox?.minLat,
bbox?.minLon,
bbox?.maxLat,
bbox?.maxLon,
filters.min_area,
filters.max_area,
filters.status,
filters.district,
filters.vri,
],
queryFn: async (): Promise<ParcelBboxItem[]> => {
if (MOCK_PARCELS_BBOX) {
// Fixture: filter by bbox + filters client-side
const typed = fixtureParcels as ParcelBboxItem[];
const inBbox = bbox ? filterByBbox(typed, bbox) : typed;
return applyFilters(inBbox, filters);
}
if (!bbox) return [];
const params = new URLSearchParams({
min_lat: String(bbox.minLat),
min_lon: String(bbox.minLon),
max_lat: String(bbox.maxLat),
max_lon: String(bbox.maxLon),
});
if (filters.min_area != null)
params.set("min_area", String(filters.min_area));
if (filters.max_area != null)
params.set("max_area", String(filters.max_area));
if (filters.status) params.set("status", filters.status);
if (filters.district) params.set("district", filters.district);
if (filters.vri) params.set("vri", filters.vri);
// Backend returns { parcels, count, limit, bbox_area_km2 } with field
// names centroid_lat/centroid_lon/area_m2 and nullable status. Adapt to
// the flat ParcelBboxItem[] shape the map components consume. district /
// vri / address are not in B1 yet — defaulted until enrichment lands.
const raw = await apiFetch<{
parcels: Array<{
cad_num: string;
centroid_lat: number;
centroid_lon: number;
area_m2: number | null;
land_category: string | null;
status: ParcelStatus | null;
}>;
}>(`/api/v1/parcels/by-bbox?${params.toString()}`);
const adapted: ParcelBboxItem[] = raw.parcels.map((p) => ({
cad_num: p.cad_num,
lat: p.centroid_lat,
lon: p.centroid_lon,
area_ha: p.area_m2 != null ? p.area_m2 / 10000 : 0,
status: p.status ?? "free",
district: "—",
vri: "other",
address: "",
}));
// Apply filters client-side until backend supports them (B1 follow-up)
return applyFilters(adapted, filters);
},
enabled: MOCK_PARCELS_BBOX || bbox != null,
staleTime: 30_000,
});
}
// ── Hook: useRecentParcels ────────────────────────────────────────────────────
interface RecentParcelsResponse {
parcels: RecentParcel[];
}
export function useRecentParcels() {
return useQuery({
queryKey: ["recent-parcels"],
queryFn: async (): Promise<RecentParcel[]> => {
const localItems = getLocalRecentParcels();
if (MOCK_RECENT_PARCELS) {
return localItems;
}
try {
const serverData = await apiFetch<RecentParcelsResponse>(
"/api/v1/users/me/recent-parcels",
);
// Merge: server list + local items not already in server list
const serverCads = new Set(serverData.parcels.map((p) => p.cad_num));
const localOnly = localItems.filter((p) => !serverCads.has(p.cad_num));
return [...serverData.parcels, ...localOnly].slice(0, MAX_RECENT);
} catch {
// B2 stub may not be deployed yet — fall back to localStorage
return localItems;
}
},
staleTime: 60_000,
});
}
// ── Types: B5 extended analyze response ──────────────────────────────────────
export interface ParcelEgrn {
cad_num: string;
address: string;
area_m2: number;
vri: string;
category: string;
registration_date: string | null;
owner_type: string;
encumbrance: string;
status: string;
last_updated: string | null;
}
// ── Types: utilities (engineering nearby) ────────────────────────────────────
export type UtilitySubtype =
| "substation"
| "pipeline"
| "power_line"
| "water_intake"
| "pumping_station"
| string; // open for future subtypes
export interface UtilitySummaryItem {
subtype: UtilitySubtype;
nearest_m: number;
name: string | null;
count_within_2km: number;
}
export interface UtilitiesData {
summary: UtilitySummaryItem[];
power_line_охранная_зона_flag: boolean;
note: string | null;
}
export interface ParcelAnalyzeResponse {
cad_num: string;
source: string;
geom_geojson: unknown;
district: {
district_name: string;
median_price_per_m2: number;
dist_to_center: number;
} | null;
score: number;
score_label?: string;
score_explanation?: string;
score_breakdown: Record<
string,
Array<{
name: string | null;
distance_m: number;
last_edit: string | null;
lat: number;
lon: number;
}>
>;
poi_count: number;
/** EGRN data — may be null if B5 extended not yet deployed */
egrn?: ParcelEgrn | null;
/** Engineering / utilities nearby — present when NSPD data available */
utilities?: UtilitiesData | null;
}
// ── Types: B6 poi-score response ─────────────────────────────────────────────
export interface PoiScoreItem {
category: string;
name: string;
distance_m: number;
weight: number;
score_contribution: number;
}
export interface PoiScoreResponse {
cad_num: string;
poi_weighted_score: number;
items: PoiScoreItem[];
}
// ── Hook: useParcelAnalyzeQuery (B5) ─────────────────────────────────────────
export function useParcelAnalyzeQuery(cad: string) {
return useQuery({
queryKey: ["parcel-analyze", cad],
queryFn: async (): Promise<ParcelAnalyzeResponse> => {
if (MOCK_ANALYZE) {
// Return fixture data regardless of cad — for dev only
return fixtureAnalyze as ParcelAnalyzeResponse;
}
return apiFetch<ParcelAnalyzeResponse>(
`/api/v1/parcels/${encodeURIComponent(cad)}/analyze`,
{ method: "POST" },
);
},
staleTime: 5 * 60_000, // 5 min — analyze is expensive
retry: 1,
});
}
// ── Hook: useParcelPoiScoreQuery (B6) ────────────────────────────────────────
/**
* Raw shape from backend /api/v1/parcels/{cad}/poi-score.
* Backend returns top_poi array with address field (not score_contribution).
* We adapt to PoiScoreResponse so downstream components are stable.
*/
interface PoiScoreRaw {
cad_num: string;
radius_m: number;
top_poi: Array<{
name: string;
category: string;
distance_m: number;
weight: number;
address?: string | null;
}>;
}
export function useParcelPoiScoreQuery(cad: string) {
return useQuery({
queryKey: ["parcel-poi-score", cad],
queryFn: async (): Promise<PoiScoreResponse> => {
if (MOCK_POI_SCORE) {
return fixturePoiScore as PoiScoreResponse;
}
// Backend returns {cad_num, radius_m, top_poi: [{name,category,distance_m,weight,address}]}
// Adapt to PoiScoreResponse {cad_num, poi_weighted_score, items} for stable consumer API.
const raw = await apiFetch<PoiScoreRaw>(
`/api/v1/parcels/${encodeURIComponent(cad)}/poi-score`,
);
const items: PoiScoreItem[] = (raw.top_poi ?? []).map((p) => ({
category: p.category,
name: p.name,
distance_m: p.distance_m,
weight: p.weight,
// score_contribution not in backend response — derive from weight (0..1) × 100
score_contribution: Math.round(p.weight * 100),
}));
const poi_weighted_score = items.reduce(
(s, p) => s + p.score_contribution,
0,
);
return { cad_num: raw.cad_num, poi_weighted_score, items };
},
staleTime: 5 * 60_000,
retry: 1,
});
}