/* ============================================================================= * D03_PlanAnalysis_UI_Page.ts * D 예정노선 1단계 노선분석(요구서 389 3장 6~13 · 440 · 452 · PLAN 562-3 · 563-1 · 579-3 · 580 · 591-2) — B05~B09 틀(`mountGroupStep`). * * 메인 = 평면(바탕 겹 켜고 끔 — 위성 · 등고선 · 세류 · 지적 · 도로 · 급경사 + 그은 선 · 측점 · 연도 구분) + 도구 여섯. * 좌 = 지형(조용히 받기 — 처음 = 등록 기준점 둘레 1 km · 지점 · 선 · 지도 보기 가운데가 받은 범위 밖이면 이어 받음 · * 단추 · 막는 창 없음 · 진행 = 이 칸 작은 막대 · 실패만 [다시 받기]) · 지점(목록 · 핀 · 우클릭 메뉴 = * `D03_PlanAnalysis_UI_Points`) · 그리기([긋기] · 제약 칸 · 연도 = 도구줄 「연도 마커」 끌어 놓기 585-3) · * 구역 · 연도(해 · 선 · 구간 줄 = 고르기 · 고른 줄 밝힘) · [저장] [초기화]. * 고른 구간 속성(연도 · 넓이 · 방향 · 지움) = 지도 위 속성 판 `D03_PlanAnalysis_UI_Props`(585-2). * 지점 = 긋기에 붙는 점(핀 · 늘 맨 위) · 기준점 = 처음 받는 기준일 뿐(노선 무관) · 고르기 · 고치기 = `D03_PlanAnalysis_UI_Edit`. * * 데이터 3층(CLAUDE.md 5장) — 지점 · 긋기 · 마크 · 연도 = 캐시(`d03-plan` {version: 3, network, points}) · [저장] = * PUT plan/network(지점 함께 · 서버가 길이 · 측점 · 조각 · 연도 행을 다시 셈 · 응답으로 바꿈) · [초기화] = 빈 선 PUT(지점 남김). * 되돌리기 · 다시하기 = 이 화면 스택(걸음 = `commit` 한 번). 화면 전체 잠금 = [저장] · [초기화]만. * 공유 회사(산림청 아님) = 보기만 — 긋기 · 받기 · [저장] 없음. * ========================================================================== */ import "./D03_PlanAnalysis_UI_Style.css"; import { ROUTES } from "@config/config_frontend"; import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; import { askConfirm } from "@ui/ui_template_modal"; import { createProgressCircle } from "@ui/ui_template_progress"; import { createButton, hideLoadingOverlay, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { currentProjectId } from "../A00_Common/b_project_switch"; import { mountGroupStep } from "../A00_Common/group_workflow"; import { keyIs } from "../A00_Common/key_registry"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { heightAt as gridHeightAt } from "../common_util/common_util_surface_sampler"; import { fetchPlanNetwork, fetchPlanProgress, fetchPlanSurface, fetchTerrainExtent, savePlanNetwork, startTerrain, type PlaceHit, type PlanJob, type PlanNetworkView, type PlanSurface, type TerrainAim, type TerrainStart, } from "./D03_PlanAnalysis_Api"; import { openPlan3d } from "./D03_PlanAnalysis_UI_3D"; import { createPlanDraw } from "./D03_PlanAnalysis_UI_Draw"; import { createPlanEdit } from "./D03_PlanAnalysis_UI_Edit"; import { createPlanLayers } from "./D03_PlanAnalysis_UI_Layers"; import { createPlanMap, PLAN_STATION_INTERVAL_M, type PlanMapLayer, } from "./D03_PlanAnalysis_UI_Map"; import { createPlanMeasure } from "./D03_PlanAnalysis_UI_Measure"; 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 { emptyNetwork, fromServer, lineNames, marksOf, normalizePieces, 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; points: Vertex[]; pointNames: string[]; site: Site | null; } 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; let points: Vertex[] = []; // 지점(긋기에 붙는 점 · 591-2) let pointNames: string[] = []; // 지점 이름(596 · 597-2) let site: Site | null = null; // 위치(캐시 · [저장] 때 정본 596-2) let savedSite: Site | null = null; let epsg = 0; let yearStart = new Date().getFullYear(); // 새 선 처음 해 let network: PlanNetwork = emptyNetwork(); let savedJson = JSON.stringify({ network, points, pointNames, site }); // 못 읽은 채 떠남 = 고침 없음 let undoStack: Snap[] = []; let redoStack: Snap[] = []; let surface: PlanSurface | null = null; let have: [number, number, number, number] | null = null; let viewMid: Vertex | null = null; // 지도 보기 가운데(지점 [추가] 자리) let lastTried = ""; // 같은 받기를 거듭하지 않음 let ready = false; // 들어올 때 받기 판단이 끝남(그 앞 보기 바뀜은 안 받음) let busy = false; let failed: string | null = null; let note = ""; // 위치 칸 알림(기준점 없음 · 못 불러옴) let measuring = false; let lastSaveMs: number | null = null; const z = (x: number, y: number): number => (surface ? gridHeightAt(surface.grid, x, y) : NaN); const draw = createPlanDraw({ base: () => points, network: () => network, heightAt: () => z, commit, onMode: () => { if (draw.mode() !== "idle") setMeasure(false); refresh(); }, }); const edit = createPlanEdit({ network: () => network, base: () => points, commit, show: (next) => { network = normalizePieces(next, yearStart); map.redraw(); }, enabled: () => draw.mode() === "idle" && !measuring, editable: () => editable, onSelect: () => refresh(), }); const props = createPlanProps({ network: () => network, edit, editable: () => editable, yearStart: () => yearStart, commit, }); const pieces = createPlanPieces({ network: () => network, edit, editable: () => editable, yearOptions: props.yearOptions, commit, }); const measure = createPlanMeasure( () => z, () => network, ); const marks = createPlanPoints({ points: () => points, network: () => network, names: () => pointNames, epsg: () => epsg, draw, commit: (nextPoints, nextNames, next) => step({ network: next, points: nextPoints, pointNames: nextNames, site }), center: () => viewMid, show: (nextPoints, next) => { [points, network] = [nextPoints, next]; map.redraw(); }, idle: () => draw.mode() === "idle" && !measuring, editable: () => editable, }); // 지도 보기 가운데 — 그릴 때마다 작업 좌표로(지점 [추가] 자리 · 보기만 옮김 = 안 받음 596-4) const viewWatch: PlanMapLayer = { draw(context, screen) { const [w, h] = [context.canvas.clientWidth, context.canvas.clientHeight]; viewMid = w ? screen.toMetric(...screen.unrotate(w / 2, h / 2)) : null; }, }; const map = createPlanMap({ onUndo: undo, onRedo: redo, on3d: open3d, onMeasure: () => setMeasure(!measuring), }); const backdrop = createPlanLayers({ projectId, map, points: () => [...points, ...allVertices()], have: () => have, }); // 받기 진행 = 지도 오른쪽 아래 작은 서클(받을 때만 · 지도 조작 안 막음 596-3) const circle = createProgressCircle({ size: 40, corner: true }); circle.root.hidden = true; map.root.append(backdrop.bar, backdrop.legend, props.panel, circle.root); const main = document.createElement("div"); main.className = "d03-plan"; main.append(map.root); const siteBox = createPlanSite({ onPick: (hit) => void pickSite(hit), onRetry: () => { [failed, lastTried, ready] = [null, "", true]; void ensureCovered(true); }, onUpload: (files) => void uploadRoads(files), }); const siteCard = createPanelCard("위치", false); siteCard.append(...siteBox.parts); const pointCard = createPanelCard("지점", false); pointCard.append(marks.panel); const drawCard = createPanelCard("그리기", false); drawCard.append(draw.panel); const pieceCard = createPanelCard("구역 · 연도", false); pieceCard.append(pieces.list); const saveButton = createButton({ label: "저장", variant: "filled", onClick: () => void save() }); const resetButton = createButton({ label: "초기화", variant: "ghost", onClick: () => void reset(), }); const actions = document.createElement("div"); actions.className = "ui-sidebar-actions"; actions.append(saveButton, resetButton); const panel = document.createElement("div"); panel.className = "ui-sidebar-panel d03-plan__panel"; panel.append(siteCard, pointCard, drawCard, pieceCard, actions); attachCollapsible(panel); // 떠날 때(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) 고친 선이 있으면 공용 물음 [취소][폐기][저장](572-3) mountGroupStep(root, ROUTES.D03_PLAN_ANALYSIS, { mainContent: main, leftPanel: panel, dirty: () => editable && dirty(), save, discard: () => clearState("d03-plan", projectId), }); root.querySelector(".ui-workflow-layout")?.classList.add("d03-plan-layout"); const allVertices = (): Vertex[] => network.lines.flatMap((line) => line.vertices); const snap = (): Snap => ({ network, points, pointNames, site }); /** 위치를 옮기고 [저장] 전 — 받기 · 범위 물음에 새 기준점을 실음. */ const moved = (): TerrainAim["center"] => site && JSON.stringify(site) !== JSON.stringify(savedSite) ? { 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 fitTo = (box: Vertex[]): void => { if (!box.length) return; map.update({ fit: box }); map.fit(); }; const dirty = (): boolean => JSON.stringify(snap()) !== savedJson; const keepDraft = (): void => dirty() ? writeState("d03-plan", { version: DRAFT_VERSION, ...snap() } satisfies Draft, projectId) : clearState("d03-plan", projectId); /** 한 걸음 — 조각을 맞춰 넣고 되돌리기에 쌓음 · 캐시 · 받은 범위 밖이면 조용히 이어 받음. */ function step(next: Snap): void { undoStack = [...undoStack.slice(1 - UNDO_MAX), snap()]; redoStack = []; [network, points, pointNames, site] = [ normalizePieces(next.network, yearStart), next.points, next.pointNames, next.site, ]; keepDraft(); refresh(); void ensureCovered(); } function commit(next: PlanNetwork): void { step({ network: next, points, pointNames, site }); } function undo(): void { const last = undoStack.pop(); if (!last) return; redoStack.push(snap()); ({ network, points, pointNames, site } = last); keepDraft(); refresh(); } function redo(): void { const next = redoStack.pop(); if (!next) return; undoStack.push(snap()); ({ network, points, pointNames, site } = next); keepDraft(); refresh(); } function setMeasure(on: boolean): void { if (on === measuring) return; measuring = on; measure.clear(); if (on) draw.setMode("idle"); map.setLayers(layerList()); refresh(); } /** 겹 차례 — 핀 누름(`marks.input`)은 고르기보다 앞 · 핀 그림 · 우클릭 메뉴(`marks.pins`)는 맨 끝(맨 위). */ const layerList = (): PlanMapLayer[] => [ ...backdrop.layers, draw.layer, marks.input, edit.layer, ...(measuring ? [measure.layer] : []), viewWatch, marks.pins, ]; /** 점들이 받은 범위 안인가. */ const inside = (list: Vertex[]): boolean => !!have && list.every(([x, y]) => x >= have![0] && x <= have![2] && y >= have![1] && y <= have![3]); /** 조용히 받기(요구 453) — 지점 · 선이 받은 범위 밖이면 이어 받음(지도 보기만 옮김 = 안 받음 596-4) · * 단추 · 막는 창 없음 · 실패 · 끊김 = [다시 받기] · 같은 요청은 한 번만. */ 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); if (!again && inside(extra)) return; // 받을 때 서버가 여유를 더함 const key = JSON.stringify([have, extra]); if (key === lastTried) return; lastTried = key; await download(extra); } function refresh(): void { draw.sync(); edit.sync(); map.setTools({ undo: editable && undoStack.length > 0, 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(); pieces.render(); marks.render(); siteBox.render({ address: site?.address ?? null, note: failed ? `지형 받기 실패 — ${failed}` : note, retry: !busy && !!failed, editable, }); saveButton.hidden = resetButton.hidden = !editable; saveButton.disabled = busy || !dirty(); } /** 받기 — 멂(받은 범위에서 5 km 넘음) · 다른 TM 띠 = 물음 한 번 뒤 옛 범위 버리고 새로(`reset`) · * 아니오 = 안 받음(거짓). 이미 도는 일(다른 창)이면 그것을 지켜봄. */ async function download(extra: Vertex[]): Promise { busy = true; // 요청이 가는 동안 다른 받기를 막음(지켜보기가 이어 받음) const where: TerrainAim = { center: moved() }; try { let answer: TerrainStart = await startTerrain(projectId, null, extra, where); if (answer.status === "far" || answer.status === "band") { const band = answer.status === "band"; const ok = await askConfirm({ title: band ? "다른 좌표 띠로 옮김" : "멀리 옮김", message: band ? `${answer.message} 그은 선 · 지점을 지우고 새 자리 둘레를 받습니다.` : `${answer.message} 받은 지형을 버리고 새 자리 둘레를 받습니다.`, okLabel: "새로 받기", danger: band, }); if (!ok) { busy = false; refresh(); return false; } if (answer.status === "band") clearForBand(answer.center_epsg); answer = await startTerrain(projectId, null, [], { ...where, reset: true }); } } catch (error) { if (!/이미 돌고/.test(errorText(error))) { busy = false; refresh(); showToast(errorText(error), "error"); return false; } } await follow(); return true; } /** 다른 TM 띠 — 작업 좌표가 바뀜 · 선 · 지점을 걷고 되돌리기를 비움(옛 좌표라 못 되돌림). */ function clearForBand(next: number): void { epsg = next; [network, points, pointNames] = [emptyNetwork(), [], []]; [undoStack, redoStack, have] = [[], [], null]; map.update({ epsg }); keepDraft(); } /** 위치 칸 고름 — 기준점 캐시(한 걸음 · [저장] 때 정본) → 지도 이동 → 둘레 받기(멂 · 띠 = 물음 · 아니오 = 되돌림). */ async function pickSite(hit: PlaceHit): Promise { if (busy) return void showToast("지형을 받는 중 — 끝난 뒤 다시 고르세요.", "info"); epsg ||= hit.lon < 126 ? 5185 : hit.lon < 128 ? 5186 : 5187; // 기준점 없던 프로젝트(서버 띠 규칙) [note, failed, lastTried, ready] = ["", null, "", false]; // 걸음의 조용히 받기는 막고 아래에서 한 번 step({ ...snap(), site: { lon: hit.lon, lat: hit.lat, address: hit.address || hit.title } }); ready = true; fitTo(around(site)); if (await download([])) return; // 새 자리 둘레(center) — 지점 · 선은 끝난 뒤 조용히 받기가 undo(); // 물음에 아니오 · 받기 못 함 = 위치 그대로 redoStack.pop(); fitTo(around(site)); } /** 임도망 올리기 — 지도 맞춤 = 주소지 둘레 ∪ 잘린 임도 상자(없으면 주소지). */ 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]); } /** 도는 받기를 지켜보고 끝나면 지형 · 바탕을 다시 읽음. */ async function follow(): Promise { const job = await watch(); if (!job || !root.isConnected) return; failed = job.error; await Promise.all([loadSurface(), backdrop.load()]); refresh(); 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(); } } async function loadSurface(): Promise { try { surface = await fetchPlanSurface(projectId); const extent = await fetchTerrainExtent(projectId, null, []); have = extent.have_m; } catch (error) { showToast(`지형면을 읽지 못했습니다 — ${errorText(error)}`, "error"); } } /** 서버 응답 → 지금 값 · 저장본(지점 · 선). */ function takeServer(view: PlanNetworkView): void { if (view.year_start) yearStart = view.year_start; const server = view.network; network = server && server.lines.length ? fromServer(server.lines, server.marks, server.pieces, yearStart) : emptyNetwork(); points = view.base.points.map((p): Vertex => [p.x, p.y]); pointNames = view.base.points.map((p) => p.name ?? ""); site = savedSite = view.base.site; if (view.base.epsg) epsg = view.base.epsg; savedJson = JSON.stringify(snap()); } function serverBody(): Parameters[1] { const { lines, pieces } = network; const body = { epsg, points, point_names: pointNames, lines, marks: marksOf(network), pieces }; return moved() && site ? { ...body, site } : body; } /** [저장] — 서버가 다시 셈 · 실패면 그대로(캐시 남음). 참 = 저장됨. */ async function save(): Promise { draw.setMode("idle"); showLoadingOverlay(); try { const began = performance.now(); takeServer(await savePlanNetwork(projectId, serverBody())); lastSaveMs = Math.round(performance.now() - began); clearState("d03-plan", projectId); undoStack = []; redoStack = []; refresh(); showToast("예정노선을 저장했습니다.", "success"); return true; } catch (error) { showToast(`저장하지 못했습니다 — ${errorText(error)}`, "error"); return false; } finally { hideLoadingOverlay(); } } /** [초기화] = 선만 지운 빈 노선을 정본에 · 지점은 남김(요구 453). */ async function reset(): Promise { const ok = await askConfirm({ title: "초기화", message: "그은 선 · 갈래 · 구역을 모두 지웁니다(지점은 남김). 저장된 노선도 바뀝니다.", okLabel: "초기화", danger: true, }); if (!ok) return; draw.setMode("idle"); showLoadingOverlay(); try { const body = { epsg, points, point_names: pointNames, lines: [], marks: [], pieces: [] }; takeServer(await savePlanNetwork(projectId, body)); clearState("d03-plan", projectId); undoStack = []; redoStack = []; refresh(); showToast("선을 지웠습니다(지점은 남김).", "success"); } catch (error) { showToast(`초기화하지 못했습니다 — ${errorText(error)}`, "error"); } finally { hideLoadingOverlay(); } } function open3d(): void { if (!surface) return; const names = lineNames(network.lines); const lines = network.lines .map((line, index) => ({ name: names[index], line: line.vertices, branch: line.branch !== null, })) .filter((item) => item.line.length > 1); openPlan3d(projectId, surface, lines, PLAN_STATION_INTERVAL_M); } // 검증 창구 — 꼴 · 미리보기 · 제약 · 화면 자리 · 재기 · 회전 · 저장 시간(ORCA 판정) (window as unknown as { __d03Plan?: unknown }).__d03Plan = { network: () => network, base: () => points, have: () => have, busy: () => busy, site: () => site, pickSite, lineNames: () => lineNames(network.lines), screenOf: map.screenOf, screenOfLonLat: map.screenOfLonLat, preview: draw.preview, stroke: draw.stroke, selected: edit.selected, select: edit.select, setScope: edit.setScope, box: edit.box, constraint: draw.constraint, setConstraint: draw.setConstraint, mode: draw.mode, setMode: draw.setMode, measure: measure.result, rotationDeg: map.rotationDeg, history: () => ({ undo: undoStack.length, redo: redoStack.length }), dirty, saveMs: () => lastSaveMs, heightAt: z, layers: backdrop.state, }; const onKey = (event: KeyboardEvent): void => { if (!root.isConnected) return window.removeEventListener("keydown", onKey); const typing = (event.target as HTMLElement | null)?.closest?.("input, textarea, select"); if (typing || !editable) return; // 키 = 등록부(588-3 · 한글 자판도 자판 자리로) const handled = keyIs(event, "plan.undo") ? (undo(), true) : keyIs(event, "plan.redo") ? (redo(), true) : measuring && event.key === "Escape" ? (setMeasure(false), true) : draw.key(event) || edit.key(event); if (handled) { event.preventDefault(); map.redraw(); } }; window.addEventListener("keydown", onKey); map.setLayers(layerList()); marks.mount(map.root); try { editable = !!(await fetchSessionUser())?.is_forest_service; draw.setEditable(editable); const view = await fetchPlanNetwork(projectId); if (!view.base.epsg) { note = "기준점이 없습니다 — 주소를 찾아 정하세요."; ready = true; return refresh(); } epsg = view.base.epsg; takeServer(view); const draft = readState("d03-plan", projectId); if (draft?.version === DRAFT_VERSION && draft.network && Array.isArray(draft.points)) ({ network, points, pointNames, site } = draft); const drawn = [...points, ...allVertices()]; map.update({ fit: drawn.length && !moved() ? drawn : around(site), epsg }); refresh(); await loadSurface(); refresh(); await backdrop.load(); const job = (await fetchPlanProgress(projectId)).terrain; if (job.running) { ready = true; await follow(); } else if (editable && !job.error) { // 들어올 때 받은 범위가 기준점 둘레 · 지점을 못 덮으면 바로 받기(서버가 셈 · 실패 뒤엔 [다시 받기]) const extent = await fetchTerrainExtent(projectId, null, [], moved()); ready = true; if (!extent.covered) await download([]); else await ensureCovered(); } } catch (error) { note = `불러오지 못했습니다 — ${errorText(error)}`; refresh(); } }