diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index 6724f458d..4301c0d71 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -41,7 +41,6 @@ import { savePlanNetwork, startTerrain, type PlaceHit, - type PlanJob, type PlanNetworkView, type PlanSurface, type TerrainAim, @@ -62,6 +61,14 @@ import { createPlanPoints } from "./D03_PlanAnalysis_UI_Points"; import { createPlanPieces } from "./D03_PlanAnalysis_UI_Pieces"; import { createPlanProps } from "./D03_PlanAnalysis_UI_Props"; import { createPlanSite } from "./D03_PlanAnalysis_UI_Site"; +import { + aroundSite, + boxOfLonLat, + errorText, + nearSite, + watchTerrain, + type Site, +} from "./D03_PlanAnalysis_UI_Terrain"; import { emptyNetwork, fromServer, @@ -71,27 +78,12 @@ import { type PlanNetwork, 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; /** 되돌리기 쌓는 수. */ const UNDO_MAX = 100; /** 캐시 꼴 판 — 옛 꼴(지점 없음 · 갈래 고름 · 위치 없음)은 안 읽음. */ const DRAFT_VERSION = 5; -/** 기준점(위치 칸 · 등록과 한 칸 `projects.site_*`). */ -interface Site { - lon: number; - lat: number; - address: string | null; -} - /** 한 걸음 = 지점(+ 이름 · 차례 같음 · 빈 값 = 「지점 N」) + 노선 + 위치(되돌리기 · 캐시 · 고침 판정). */ interface Snap { network: PlanNetwork; @@ -104,10 +96,6 @@ interface Draft extends Snap { version: number; } -const sleep = (ms: number): Promise => new Promise((done) => setTimeout(done, ms)); -const errorText = (error: unknown): string => - error instanceof Error ? error.message : String(error); - export async function renderD03PlanAnalysis(root: HTMLElement): Promise { const projectId = currentProjectId(); let editable = false; @@ -213,7 +201,9 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { const viewWatch: PlanMapLayer = { draw(context, screen) { const [w, h] = [context.canvas.clientWidth, context.canvas.clientHeight]; + const had = !!viewMid; viewMid = w ? screen.toMetric(...screen.unrotate(w / 2, h / 2)) : null; + if (!had && viewMid) marks.render(); // 첫 그림 뒤 가운데가 생김 = 지점 칸 [추가] 켬 }, }; const map = createPlanMap({ @@ -281,16 +271,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { const moved = (): boolean => !!site && JSON.stringify(site) !== JSON.stringify(savedSite); /** 받기 · 범위에 늘 지금 기준점(캐시)을 실음 — 받은 범위에서 멀면 서버가 물음(저장된 자리로 되돌아올 때도). */ const center = (): TerrainAim["center"] => (site ? { lon: site.lon, lat: site.lat } : null); - /** 기준점 둘레 ±1 km(작업 좌표) — 처음 맞춤 · 위치 옮김 · 임도망 맞춤. */ - const around = (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], - ]; - }; + const around = (at: Site | null): Vertex[] => aroundSite(epsg, at); const fitTo = (box: Vertex[]): void => { if (!box.length) return; map.update({ fit: box }); @@ -369,10 +350,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { * 단추 · 막는 창 없음 · 실패 · 끊김 = [다시 받기] · 같은 요청은 한 번만. */ async function ensureCovered(again = false): Promise { if (!ready || !editable || busy || failed || !epsg || !root.isConnected) return; - const [lo, hi] = around(site); - const near = ([x, y]: Vertex): boolean => - !lo || Math.hypot(x - (lo[0] + hi[0]) / 2, y - (lo[1] + hi[1]) / 2) <= COVER_MAX_M; - const extra = [...points, ...allVertices()].filter(near); + const extra = nearSite(epsg, site, [...points, ...allVertices()]); if (!again && inside(extra)) return; // 받을 때 서버가 여유를 더함 const key = JSON.stringify([have, extra]); if (key === lastTried) return; @@ -388,7 +366,6 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { redo: editable && redoStack.length > 0, view3d: !!surface && network.lines.some((line) => line.vertices.length > 1), measure: measuring, - yearMark: editable && network.lines.some((line) => line.vertices.length > 1), }); map.redraw(); props.render(); @@ -466,16 +443,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { /** 임도망 올리기 — 지도 맞춤 = 주소지 둘레 ∪ 잘린 임도 상자(없으면 주소지). */ async function uploadRoads(files: File[]): Promise { const info = await backdrop.uploadRoads(files); - const tm = tmParams(epsg); - if (!info) return; - const roads: Vertex[] = - tm && info.bounds - ? [ - lonLatToTm(tm, info.bounds[0], info.bounds[1]), - lonLatToTm(tm, info.bounds[2], info.bounds[3]), - ] - : []; - fitTo([...around(site), ...roads]); + if (info) fitTo([...around(site), ...boxOfLonLat(epsg, info.bounds)]); } /** 도는 받기를 지켜보고 끝나면 지형 · 바탕을 다시 읽음. */ @@ -488,35 +456,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { await ensureCovered(); // 받는 동안 옮긴 지점 · 선 · 보기 } - /** 지형 받기를 끝날 때까지 지켜봄 — 진행 = 지도 귀퉁이 서클 · 끊김(서버 다시 켜짐 = 끝 아닌데 멎음 · - * 진행을 못 읽음) = 실패 꼴 + [다시 받기]. */ - async function watch(): Promise { - busy = true; - circle.set(null, "지형"); - circle.root.hidden = false; - refresh(); - try { - for (;;) { - if (!root.isConnected) return null; - const job = (await fetchPlanProgress(projectId)).terrain; - circle.set(job.percent / 100, `지형 ${job.message ?? ""}`.trim()); - circle.root.title = `지형 받기 ${job.percent}% · ${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 { - busy = false; - circle.root.hidden = true; - refresh(); - } - } + const watch = () => + watchTerrain({ + projectId, + alive: () => root.isConnected, + circle, + setBusy: (on) => { + busy = on; + refresh(); + }, + }); async function loadSurface(): Promise { try { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Terrain.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Terrain.ts new file mode 100644 index 000000000..e0626d79d --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Terrain.ts @@ -0,0 +1,94 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_Terrain.ts + * D 예정노선 받기 이음(PLAN 597 · 요구 463-7) — 페이지에서 뗀 조각: 기준점 꼴 · 둘레 상자 · 받기 지켜보기(서클). + * 상태는 페이지가 쥐고 여기는 인자로만 받음(화면 요소 만들기 없음 · 서클은 부품 그대로). + * ========================================================================== */ + +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, + ); +} + +export interface WatchParams { + projectId: string; + alive: () => boolean; + circle: ReturnType; + /** 받는 중 켬끔(페이지 `busy`) 뒤 화면 갱신. */ + setBusy: (on: boolean) => void; +} + +/** 지형 받기를 끝날 때까지 지켜봄 — 진행 = 지도 귀퉁이 서클 · 끊김(서버 다시 켜짐 = 끝 아닌데 멎음 · + * 진행을 못 읽음) = 실패 꼴 + [다시 받기]. */ +export async function watchTerrain(params: WatchParams): Promise { + const { projectId, alive, circle, setBusy } = params; + setBusy(true); + circle.set(null, "지형"); + circle.root.hidden = false; + try { + for (;;) { + if (!alive()) return null; + const job = (await fetchPlanProgress(projectId)).terrain; + circle.set(job.percent / 100, `지형 ${job.message ?? ""}`.trim()); + circle.root.title = `지형 받기 ${job.percent}% · ${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 { + circle.root.hidden = true; + setBusy(false); + } +}