/* ============================================================================= * D03_PlanAnalysis_UI_Page.ts * D 예정노선 2단계 노선분석(요구서 389 3장 6~13 · 440 · PLAN 562-3 · 563-1 · 579-3 · 580) — B05~B09 틀(`mountGroupStep`). * * 메인 = 평면(바탕 겹 켜고 끔 — 위성 · 등고선 · 세류 · 지적 · 도로 · 급경사 + 그은 선 · 측점 · 연도 구분) + 도구 여섯. * 좌 = 지형(들어올 때 받은 범위가 지점을 못 덮으면 바로 받기 · [범위 밖 더 받기]는 지점 · 선이 * 받은 범위 밖일 때만) · 그리기([긋기] · 제약 칸 · 연도 = 도구줄 「연도 마커」 끌어 놓기 585-3) · * 구역 · 연도(해 · 선 · 구간 줄 = 고르기 · 고른 줄 밝힘) · [저장] [초기화]. * 고른 구간 속성(연도 · 넓이 · 방향 · 지움) = 지도 위 속성 판 `D03_PlanAnalysis_UI_Props`(585-2). * 지점 = D02 지점 목록(핀 · 늘 맨 위 · 585-1) · 고르기 · 고치기 = `D03_PlanAnalysis_UI_Edit`. * * 데이터 3층(CLAUDE.md 5장) — 긋기 · 마크 · 연도 = 캐시(`d03-plan` {version: 2, network}) · [저장] = * 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 { createInputField } from "@ui/ui_template_input"; import { editMap } from "@ui/ui_template_kakao_map"; import { askConfirm, openModal } from "@ui/ui_template_modal"; import { createProgressBar } from "@ui/ui_template_progress"; import { createText } from "@ui/ui_template_text"; 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 PlanJob, type PlanNetworkView, type PlanSurface, type TerrainExtent, } from "./D03_PlanAnalysis_Api"; import { openPlan3d } from "./D03_PlanAnalysis_UI_3D"; import { createPlanDraw } from "./D03_PlanAnalysis_UI_Draw"; import { pointLayer } from "./D03_PlanAnalysis_UI_DrawPaint"; import { createPlanEdit } from "./D03_PlanAnalysis_UI_Edit"; import { createPlanLayers } from "./D03_PlanAnalysis_UI_Layers"; import { createPlanMap, PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map"; import { createPlanMeasure } from "./D03_PlanAnalysis_UI_Measure"; import { createPlanProps } from "./D03_PlanAnalysis_UI_Props"; import { lineLength, stationChainages } from "./logic/D03_PlanAnalysis_Draw"; import { emptyNetwork, fromServer, lineNames, marksOf, normalizePieces, type PlanNetwork, type Vertex, } from "./logic/D03_PlanAnalysis_Plan"; const POLL_MS = 1000; /** 되돌리기 쌓는 수. */ const UNDO_MAX = 100; /** 캐시 꼴 판 — 옛 꼴(지점 · 갈래 고름)은 안 읽음. */ const DRAFT_VERSION = 2; interface Draft { version: number; network: PlanNetwork; } 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[] = []; // 기준 지점(D02 · 585-1) let epsg = 0; let yearStart = new Date().getFullYear(); // 새 선 처음 해 let network: PlanNetwork = emptyNetwork(); let savedJson = JSON.stringify(network); // 못 읽은 채 떠남 = 고침 없음 let undoStack: PlanNetwork[] = []; let redoStack: PlanNetwork[] = []; let surface: PlanSurface | null = null; let have: [number, number, number, number] | null = null; let busy = false; let failed: string | null = null; 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 measure = createPlanMeasure(() => z); const pins = pointLayer(() => points); const map = createPlanMap({ onUndo: undo, onRedo: redo, on3d: open3d, onMeasure: () => setMeasure(!measuring), }); const backdrop = createPlanLayers({ projectId, map, points: () => [...points, ...allVertices()], }); map.root.append(backdrop.bar, backdrop.legend, props.panel); const main = document.createElement("div"); main.className = "d03-plan"; main.append(map.root); const terrainText = createText("", "muted"); terrainText.classList.add("d03-plan__hint"); const bar = createProgressBar(); bar.root.hidden = true; const extentButton = createButton({ label: "지형 범위 보기", variant: "filled", size: 28, onClick: () => void openExtent([]), }); const moreButton = createButton({ label: "범위 밖 더 받기", variant: "filled", size: 28, onClick: () => void openExtent(allVertices()), }); const terrainCard = createPanelCard("지형", false); terrainCard.append(terrainText, bar.root, extentButton, moreButton); const drawCard = createPanelCard("그리기", false); drawCard.append(draw.panel); const pieceCard = createPanelCard("구역 · 연도", false); pieceCard.append(props.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(terrainCard, 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 dirty = (): boolean => JSON.stringify(network) !== savedJson; const keepDraft = (): void => dirty() ? writeState("d03-plan", { version: DRAFT_VERSION, network } satisfies Draft, projectId) : clearState("d03-plan", projectId); /** 한 걸음 — 조각을 맞춰 넣고 되돌리기에 쌓음 · 캐시. */ function commit(next: PlanNetwork): void { undoStack = [...undoStack.slice(1 - UNDO_MAX), network]; redoStack = []; network = normalizePieces(next, yearStart); keepDraft(); refresh(); } function undo(): void { const last = undoStack.pop(); if (!last) return; redoStack.push(network); network = last; keepDraft(); refresh(); } function redo(): void { const next = redoStack.pop(); if (!next) return; undoStack.push(network); network = next; keepDraft(); refresh(); } function setMeasure(on: boolean): void { if (on === measuring) return; measuring = on; measure.clear(); if (on) draw.setMode("idle"); map.setLayers([ ...backdrop.layers, draw.layer, edit.layer, ...(measuring ? [measure.layer] : []), pins, ]); refresh(); } /** 지점 · 꺾임점이 받은 범위 밖인가(받은 범위 없음 = 밖 아님 — 그땐 [지형 범위 보기]). */ const outside = (): boolean => !!have && [...points, ...allVertices()].some( ([x, y]) => x < have![0] || x > have![2] || y < have![1] || y > have![3], ); 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(); extentButton.hidden = !editable || busy || !!surface; moreButton.hidden = !editable || busy || !surface || !outside(); if (!busy) terrainText.textContent = failed ? `지형 받기 실패 — ${failed}` : !surface ? "받은 지형 없음 — 범위를 보고 받으세요(미리보기 길이 · 기울기는 지형을 받은 뒤)." : outside() ? "지점 또는 그은 선이 받은 지형 밖입니다 — 더 받아야 기울기를 셉니다." : "받은 지형 안"; if (failed && editable && !busy) extentButton.hidden = false; extentButton.querySelector(".ui-btn__label")!.textContent = failed ? "다시 받기" : "지형 범위 보기"; saveButton.hidden = resetButton.hidden = !editable; saveButton.disabled = busy || !dirty(); } /** 받기 전 범위를 카카오 지도에 보임 → [이 범위 받기]. */ async function openExtent(extra: Vertex[]): Promise { let extent: TerrainExtent; try { extent = await fetchTerrainExtent(projectId, null, extra); } catch (error) { return void showToast(`받을 범위를 읽지 못했습니다 — ${errorText(error)}`, "error"); } const info = createText("", "muted"); const kakao = editMap({ onMove: () => undefined, hint: "받을 범위(빨강) · 받은 범위(초록) · 도엽(옅게)", }); const show = (view: TerrainExtent): void => { const ring = (points: Array<[number, number]>): Array<[number, number]> => [...points, points[0]].map(([lon, lat]) => [lat, lon]); const lines = [ ring(view.box), ...(view.have ? [ring(view.have)] : []), ...view.sheets.map((s) => ring(s.ring)), ]; const colors = [ "#E60046", ...(view.have ? ["#16a34a"] : []), ...view.sheets.map(() => "#94a3b8"), ]; kakao.set([], lines, colors); const newSheets = view.sheets.filter((sheet) => !sheet.stored).length; info.textContent = `여유 ${view.margin_m.toFixed(0)} m · 도엽 ${view.sheets.length}매(새로 ${newSheets}) · ` + `지형 칸 약 ${(view.cells / 1e6).toFixed(1)}백만` + (view.covered ? " · 이미 받은 범위 안" : ""); }; const margin = createInputField({ label: "여유", type: "number", value: extent.margin_m.toFixed(0), unit: "m", size: 24, }); const applyMargin = createButton({ label: "다시 보기", variant: "ghost", size: 24, onClick: () => void fetchTerrainExtent(projectId, Number(margin.input.value) || null, extra) .then((view) => show((extent = view))) .catch((error) => showToast(errorText(error), "error")), }); const row = document.createElement("div"); row.className = "d03-plan__row"; row.append(margin.root, applyMargin); let close = (): void => undefined; const go = createButton({ label: "이 범위 받기", variant: "filled", onClick: () => { close(); void download(extent.margin_m, extra); }, }); ({ close } = openModal({ title: "지형 받기 — 범위 미리보기", closeLabel: "닫기", width: "960px", dialogClass: "d03-plan__extent-modal", actions: [go], mount: (body) => { body.classList.add("d03-plan__extent"); body.append(row, info, kakao.root); show(extent); }, })); } async function download(marginM: number, extra: Vertex[]): Promise { try { await startTerrain(projectId, marginM, extra); } catch (error) { // 이미 도는 일(다른 창)이면 그것을 지켜봄 if (!/이미 돌고/.test(errorText(error))) return void showToast(errorText(error), "error"); } await follow(); } /** 도는 받기를 지켜보고 끝나면 지형 · 바탕을 다시 읽음. */ async function follow(): Promise { const job = await watch(); if (!job || !root.isConnected) return; failed = job.error; await Promise.all([loadSurface(), backdrop.load()]); refresh(); } /** 지형 받기를 끝날 때까지 지켜봄 — 진행은 지형 칸에. */ async function watch(): Promise { busy = true; bar.root.hidden = false; refresh(); try { for (;;) { if (!root.isConnected) return null; const job = (await fetchPlanProgress(projectId)).terrain; bar.set(job.percent / 100); terrainText.textContent = `지형 받기 ${job.percent}% · ${job.message ?? ""}`; if (!job.running) { if (job.error) showToast(job.error, "error"); return job; } await sleep(POLL_MS); } } catch (error) { showToast(`진행을 읽지 못했습니다 — ${errorText(error)}`, "error"); return null; } finally { busy = false; bar.root.hidden = true; refresh(); } } async function loadSurface(): Promise { try { surface = await fetchPlanSurface(projectId); const extent = surface ? await fetchTerrainExtent(projectId, null, []) : null; have = extent?.have_m ?? null; } 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(); if (server?.epsg) epsg = server.epsg; savedJson = JSON.stringify(network); } function serverBody(): Parameters[1] { return { epsg, lines: network.lines, marks: marksOf(network), pieces: network.pieces }; } /** [저장] — 서버가 다시 셈 · 실패면 그대로(캐시 남음). 참 = 저장됨. */ 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(); } } /** [초기화] = 시점 · 종점만 남은 빈 선을 정본에. */ async function reset(): Promise { const ok = await askConfirm({ title: "초기화", message: "그은 선 · 갈래 · 구역을 모두 지우고 시점 · 종점만 남깁니다. 저장된 노선도 바뀝니다.", okLabel: "초기화", danger: true, }); if (!ok) return; draw.setMode("idle"); showLoadingOverlay(); try { takeServer(await savePlanNetwork(projectId, { epsg, lines: [], marks: [], pieces: [] })); 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, stations: stationChainages(lineLength(line.vertices), PLAN_STATION_INTERVAL_M), })) .filter((item) => item.line.length > 1); openPlan3d(projectId, surface, lines); } // 검증 창구 — 꼴 · 미리보기 · 제약 · 화면 자리 · 재기 · 회전 · 저장 시간(ORCA 판정) (window as unknown as { __d03Plan?: unknown }).__d03Plan = { network: () => network, base: () => points, 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([...backdrop.layers, draw.layer, edit.layer, pins]); try { editable = !!(await fetchSessionUser())?.is_forest_service; draw.setEditable(editable); const view = await fetchPlanNetwork(projectId); const base = view.base; if (!base.points.length || !base.epsg) { terrainText.textContent = "지점이 비었습니다 — 1단계(기준 지정)에서 정하세요."; return; } points = base.points.map((p): Vertex => [p.x, p.y]); epsg = base.epsg; takeServer(view); const draft = readState("d03-plan", projectId); if (draft?.version === DRAFT_VERSION && draft.network?.lines?.length) network = draft.network; map.update({ fit: [...points, ...allVertices()], epsg }); refresh(); await loadSurface(); refresh(); await backdrop.load(); const job = (await fetchPlanProgress(projectId)).terrain; if (job.running) await follow(); else if (editable && !job.error) { // 들어올 때 받은 범위가 시점 · 종점을 못 덮으면 바로 받기(요구 441 1 · 실패한 뒤엔 [다시 받기]) const extent = await fetchTerrainExtent(projectId, null, []); if (!extent.covered) await download(extent.margin_m, []); } } catch (error) { terrainText.textContent = `불러오지 못했습니다 — ${errorText(error)}`; } }