/* ============================================================================= * D03_PlanAnalysis_UI_Terrain.ts * D 예정노선 받기 이음(PLAN 597 · 604 · 요구 463-7 · 475 · 476-2) — 페이지에서 뗀 조각: 기준점 꼴 · 둘레 상자 · * 받기 지켜보기 · 진행 서클 하나(위치 칸 제목 오른쪽 · 들어간 일 수 = 지형 받기 · 겹 받기 · 처음 열기 · 임도망 올리기 · * LAS 올리기 624-4 = 진행률 「라이다」). * 상태는 페이지가 쥐고 여기는 인자로만 받음(서클은 부품 그대로). * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; import type { createProgressCircle } from "@ui/ui_template_progress"; import { fetchPlanProgress, type PlanJob } from "./D03_PlanAnalysis_Api"; import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; import { lonLatToTm, tmParams } from "./logic/D03_PlanAnalysis_Tm"; const POLL_MS = 1000; /** 받기가 끊김(서버 다시 켜짐 · 진행을 못 읽음) — [다시 받기]. */ const INTERRUPTED = "끊김(서버 다시 켜짐 등)"; /** 처음 맞춤 = 기준점 둘레(서버 `SITE_RADIUS_M`). */ const SITE_RADIUS_M = 1000; /** 조용히 받기가 덮는 거리 — 기준점에서 이보다 먼 지점 · 선은 안 받음(위치를 멀리 옮긴 뒤 옛 선 = 거대 받기 막음). */ const COVER_MAX_M = 10000; /** 기준점(위치 칸 · 등록과 한 칸 `projects.site_*`). */ export interface Site { lon: number; lat: number; address: string | null; } export const sleep = (ms: number): Promise => new Promise((done) => setTimeout(done, ms)); export const errorText = (error: unknown): string => error instanceof Error ? error.message : String(error); /** 기준점 둘레 ±1 km(작업 좌표) — 처음 맞춤 · 위치 옮김 · 임도망 맞춤. */ export function aroundSite(epsg: number, at: Site | null): Vertex[] { const tm = tmParams(epsg); if (!at || !tm) return []; const [x, y] = lonLatToTm(tm, at.lon, at.lat); return [ [x - SITE_RADIUS_M, y - SITE_RADIUS_M], [x + SITE_RADIUS_M, y + SITE_RADIUS_M], ]; } /** 경위도 상자 [경도1, 위도1, 경도2, 위도2] → 작업 좌표 두 점(없음 · 띠 모름 = 빈 목록). */ export function boxOfLonLat(epsg: number, bounds: number[] | null | undefined): Vertex[] { const tm = tmParams(epsg); if (!tm || !bounds) return []; return [lonLatToTm(tm, bounds[0], bounds[1]), lonLatToTm(tm, bounds[2], bounds[3])]; } /** 기준점 둘레 상자 가운데에서 `COVER_MAX_M` 안인 점만 남김(기준점 없음 = 모두). */ export function nearSite(epsg: number, at: Site | null, list: Vertex[]): Vertex[] { const [lo, hi] = aroundSite(epsg, at); if (!lo) return list; return list.filter( ([x, y]) => Math.hypot(x - (lo[0] + hi[0]) / 2, y - (lo[1] + hi[1]) / 2) <= COVER_MAX_M, ); } /** 기준점에서 `COVER_MAX_M` 넘는 점 수 — 위치 칸 알림(조용히 안 받음 475). */ export const farFromSite = (epsg: number, at: Site | null, list: Vertex[]): number => list.length - nearSite(epsg, at, list).length; /** 진행 서클 하나 · 들어간 일 수(0 = 숨김) — 지형 받기 = 진행률 · 나머지 일 = 돎. */ export interface WorkCircle { /** 일 하나를 셈(끝나면 뺌 · 답은 그대로). */ track: (job: Promise) => Promise; /** 지형 받기 진행(없음 = 끝 · `label` = 서클 글 · 기본 「지형」). */ terrain: (state: { ratio: number; text: string; label?: string } | null) => void; /** 들어간 일 수(검증 창구). */ count: () => number; } export function createWorkCircle(circle: ReturnType): WorkCircle { let count = 0; let terrain: { ratio: number; text: string; label?: string } | null = null; const show = (): void => { circle.root.hidden = count === 0; if (terrain) circle.set(terrain.ratio, terrain.label ?? "지형"); else circle.set(null, count > 1 ? `받는 중 ${count}` : "받는 중"); circle.root.title = terrain ? `${terrain.label ?? "지형"} 받기 ${Math.round(terrain.ratio * 100)}% · ${terrain.text}` : `겹 · 파일 받는 중 ${count}`; }; show(); return { track(job) { count += 1; show(); return job.finally(() => { count -= 1; show(); }); }, terrain(state) { terrain = state; show(); }, count: () => count, }; } export interface WatchParams { projectId: string; alive: () => boolean; work: WorkCircle; /** 받는 중 켬끔(페이지 `busy`) 뒤 화면 갱신. */ setBusy: (on: boolean) => void; } /** 지형 받기를 끝날 때까지 지켜봄 — 진행 = 위치 칸 서클 · 끊김(서버 다시 켜짐 = 끝 아닌데 멎음 · * 진행을 못 읽음) = 실패 꼴 + [다시 받기]. */ export function watchTerrain(params: WatchParams): Promise { return params.work.track(watching(params)); } async function watching(params: WatchParams): Promise { const { projectId, alive, work, setBusy } = params; setBusy(true); work.terrain({ ratio: 0, text: "" }); try { for (;;) { if (!alive()) return null; const job = (await fetchPlanProgress(projectId)).terrain; work.terrain({ ratio: job.percent / 100, text: job.message ?? "" }); if (!job.running) { const cut = !job.error && (job.interrupted || job.percent < 100); if (job.error) showToast(job.error, "error"); return cut ? { ...job, error: INTERRUPTED } : job; } await sleep(POLL_MS); } } catch (error) { showToast(`진행을 읽지 못했습니다 — ${errorText(error)}`, "error"); return { running: false, percent: 0, message: null, error: INTERRUPTED }; } finally { work.terrain(null); setBusy(false); } }