/* ============================================================================= * D03_PlanAnalysis_UI_Page.ts * D 예정노선 2단계 분석 화면(요구서 345 3-3 · PLAN 530-3) — B05~B09 틀(`mountGroupStep`). * * 메인 = 평면(위성 + 도엽 등고선 + 지점 + 연도 구간 노선 · 측점) · 하 = 누른 측점 지반선 횡단 · * 좌 = 지형 받기 진행 · 연도별 갈래 넷 카드 · [분석] [저장] [3D]. * 열리면 지형 받기(뒤에서 · 받은 지형 안이면 건너뜀) · 끈 지점이 받은 범위 밖 = 「범위 밖 — 더 받기」. * * 데이터 3층(CLAUDE.md 5장) — 지점 끌기 · 갈래 고름 = 캐시(`d03-plan`) · [저장] = PUT plan/analysis * (끈 지점은 서버가 D02 규칙으로 정본에 씀 · 노선은 서버 저장본으로 다시 셈). 화면 전체 잠금 = [저장]만. * 공유 회사(산림청 아님) = 보기만 — 끌기 · [분석] · [저장] · 받기 없음. * ========================================================================== */ import "./D03_PlanAnalysis_UI_Style.css"; import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; import { createButton, hideLoadingOverlay, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { fetchCachedSheetLayer, DRAINAGE_SHEET_LAYERS } from "../A00_Common/b_asset_cache"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { mountGroupStep } from "../A00_Common/group_workflow"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { fetchVWorldMeta, getVWorldMapUrl, type VWorldMeta, } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { createNormalizer, type GeoJsonCollection, type PreparedLayer, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import { prepareLayer } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare"; import { stationLabel } from "../common_util/common_util_station"; import type { StationGround } from "../B05_Profile/logic/B05_Profile_Sections_Ground"; import { fetchPlanAnalysis, fetchPlanPoints, fetchPlanProgress, fetchYearSurfaces, savePlanAnalysis, startPlanJob, type PlanAnalysisView, type PlanJob, type YearSurface, } from "./D03_PlanAnalysis_Api"; import { openPlan3d } from "./D03_PlanAnalysis_UI_3D"; import { createPlanCross } from "./D03_PlanAnalysis_UI_Cross"; import { createPlanMap, PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map"; import { chainRows, joinedLine, nodesInReach, nodesOf, PLAN_MODES, pointsOf, rowAt, sameNodes, stationChainages, type ChainRow, type Vertex, type YearAnalysis, } from "./logic/D03_PlanAnalysis_Plan"; const MODE_LABEL: Record = { gradeline: "영선", earthwork: "토공 최소", gentle: "기울기 최소", weighted: "비중 계산", }; const POLL_MS = 1000; /** 등고선을 받아 올 지점 둘레(m). */ const CONTOUR_PAD_M = 600; interface Draft { nodes: Vertex[] | null; choices: Record; } 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 = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; const map = createPlanMap({ onDrag, onDragEnd, onStation }); const cross = createPlanCross(); const main = document.createElement("div"); main.className = "d03-plan"; main.append(map.root, cross.root); const terrainText = document.createElement("p"); terrainText.className = "d03-plan__hint"; const bar = document.createElement("progress"); bar.className = "d03-plan__bar"; bar.max = 100; bar.hidden = true; const more = createButton({ label: "범위 밖 — 더 받기", variant: "filled", onClick: () => void download(), }); more.hidden = true; const yearList = document.createElement("div"); yearList.className = "d03-plan__years"; const analyzeButton = createButton({ label: "분석", variant: "ghost", onClick: () => void analyze(), }); const saveButton = createButton({ label: "저장", variant: "filled", onClick: () => void save() }); const viewButton = createButton({ label: "3D", variant: "ghost", onClick: () => open3d() }); const actions = document.createElement("div"); actions.className = "ui-sidebar-actions"; actions.append(analyzeButton, saveButton, viewButton); const terrainCard = createPanelCard("지형", false); terrainCard.append(terrainText, bar, more); const routeCard = createPanelCard("연도별 노선", false); routeCard.append(yearList); const panel = document.createElement("div"); panel.className = "ui-sidebar-panel d03-plan__panel"; panel.append(terrainCard, routeCard, actions); attachCollapsible(panel); mountGroupStep(root, ROUTES.D03_PLAN_ANALYSIS, { mainContent: main, leftPanel: panel }); root.querySelector(".ui-workflow-layout")?.classList.add("d03-plan-layout"); let years: number[] = []; let savedNodes: Vertex[] = []; let nodes: Vertex[] = []; let choices: Record = {}; let startM = 0; let targetM: number | null = null; // 등록 목표 끝 누가 let analysis: PlanAnalysisView | null = null; let surfaces = new Map(); let rows: ChainRow[] = []; let results: YearAnalysis[] = []; let selected: number | null = null; let editable = false; let busy: "terrain" | "analyze" | null = null; let lastCross: StationGround | null = null; const moved = (): boolean => !sameNodes(nodes, savedNodes); const sentPoints = () => (moved() ? pointsOf(years, nodes) : null); const reach = (): boolean[] => nodesInReach( nodes, years.map((year) => { const grid = surfaces.get(year)?.grid; return grid ? { x0: grid.x[0], x1: grid.x[grid.x.length - 1], y0: grid.y[0], y1: grid.y[grid.y.length - 1], } : null; }), ); const keepDraft = (): void => writeState("d03-plan", { nodes: moved() ? nodes : null, choices } satisfies Draft, projectId); /** 저장본 · 고름 → 이은 누가 · 중심선 · 측점 → 평면 · 카드 · 단추. */ function refresh(): void { results = years.map( (year, i) => analysis?.years.find((entry) => entry.year === year)?.analysis ?? { year, start: nodes[i], end: nodes[i + 1], routes: {}, }, ); // 고름 = 저장된 갈래(서버 이은 누가) 위에 캐시 고름 const saved = Object.fromEntries((analysis?.chain ?? []).map((row) => [row.year, row.mode])); rows = chainRows(results, { ...saved, ...choices }, startM); const line = joinedLine(results, rows); const others: Vertex[][] = results.flatMap((result, i) => { const routes = PLAN_MODES.filter((mode) => mode !== rows[i].mode) .map((mode) => result.routes[mode]?.planned) .filter((planned): planned is Vertex[] => !!planned && planned.length > 1); return rows[i].mode ? routes : [[nodes[i], nodes[i + 1]]]; }); const inReach = reach(); map.update({ nodes, reach: inReach, others, line, startM, stations: stationChainages(rows, PLAN_STATION_INTERVAL_M), selected, editable, }); renderYears(); const outside = inReach.some((ok) => !ok); const missing = rows.some((row) => !row.mode); more.hidden = !editable || !outside || busy !== null; if (busy === null) terrainText.textContent = outside ? "빨간 고리 지점이 받은 지형 밖입니다 — 더 받아야 분석합니다." : `받은 지형 안 · 연도 지형면 ${surfaces.size}/${years.length}`; analyzeButton.hidden = saveButton.hidden = !editable; analyzeButton.disabled = busy !== null || outside; saveButton.disabled = busy !== null || missing; saveButton.title = missing ? "모든 연도에 노선이 있어야 저장합니다 — [분석]을 먼저 하세요." : ""; viewButton.disabled = !rows.some((row) => row.mode && surfaces.has(row.year)); } function renderYears(): void { yearList.replaceChildren( ...results.map((result, i) => { const row = rows[i]; const box = document.createElement("div"); box.className = "d03-plan__year"; box.dataset.year = String(result.year); const head = document.createElement("div"); head.className = "d03-plan__year-head"; head.textContent = row.mode ? `${result.year}년 · ${stationLabel(row.start_chainage_m, PLAN_STATION_INTERVAL_M, 1)} ~ ` + `${stationLabel(row.end_chainage_m, PLAN_STATION_INTERVAL_M, 1)} (${row.length_m.toFixed(1)} m)` : `${result.year}년 · 분석 전 — [분석]을 누르세요`; box.append(head); for (const mode of PLAN_MODES) { const route = result.routes[mode]; if (!route) continue; const card = document.createElement("button"); card.type = "button"; card.className = "d03-plan__mode" + (row.mode === mode ? " is-picked" : ""); card.dataset.mode = mode; const metrics = route.metrics; card.disabled = !!route.error || !metrics; card.title = route.error ?? ""; const name = document.createElement("strong"); name.textContent = MODE_LABEL[mode]; const values = document.createElement("span"); values.textContent = metrics ? `연장 ${metrics.length_m.toFixed(1)} m · 최대 ${(metrics.max_grade_pct ?? 0).toFixed(1)} % · 위반 ${metrics.over_limit?.total ?? 0}` : "노선 없음"; card.append(name, values); card.addEventListener("click", () => { choices = { ...choices, [result.year]: mode }; keepDraft(); refresh(); }); box.append(card); } return box; }), ...targetLine(), ); } /** 이은 끝 누가 대 등록 목표(요구서 3-1) — 모든 연도에 노선이 있을 때만 */ function targetLine(): HTMLElement[] { const last = rows[rows.length - 1]; if (targetM === null || !last || rows.some((row) => !row.mode)) return []; const diff = last.end_chainage_m - targetM; const line = document.createElement("div"); line.className = "d03-plan__year-head d03-plan__target"; line.textContent = `끝 ${stationLabel(last.end_chainage_m, PLAN_STATION_INTERVAL_M, 1)} · ` + `목표 ${stationLabel(targetM, PLAN_STATION_INTERVAL_M, 1)} · ` + `차이 ${diff >= 0 ? "+" : ""}${diff.toFixed(1)} m`; return [line]; } function onDrag(index: number, point: Vertex): void { nodes = nodes.map((node, i) => (i === index ? point : node)); map.update({ nodes, reach: reach() }); } function onDragEnd(index: number): void { // 끈 지점의 앞 · 뒤 연도 노선은 지점이 달라 빔 — 다시 [분석] if (analysis) analysis = { ...analysis, years: analysis.years.map((entry, i) => i === index - 1 || i === index ? { ...entry, analysis: null } : entry, ), }; selected = null; lastCross = null; cross.clear("지점을 옮겼습니다 — [분석] 뒤 측점을 누르세요."); keepDraft(); refresh(); } function onStation(chainage: number): void { const row = rowAt(rows, chainage); const surface = row ? surfaces.get(row.year) : undefined; if (!row?.mode || !surface) return; const result = results.find((entry) => entry.year === row.year)!; selected = chainage; map.update({ selected }); lastCross = cross.show( chainage, row.year, result.routes[row.mode].planned ?? [], Math.max(0, chainage - row.start_chainage_m), surface.grid, ); } async function loadBackground(): Promise { let meta: VWorldMeta | null = null; try { meta = await fetchVWorldMeta(projectId, "satellite"); } catch { meta = null; // 위성 못 받음 — 가짜 도엽 위에 그림 } let image: HTMLImageElement | null = null; let contours: PreparedLayer | null = null; if (meta) { image = new Image(); image.onload = () => map.update({}); // 범위가 넓어지면 주소가 바뀌어 옛 그림을 안 씀 image.src = `${getVWorldMapUrl(projectId, "satellite")}&v=${meta.x_min}_${meta.x_max}_${meta.y_min}_${meta.y_max}`; const xs = nodes.map(([x]) => x); const ys = nodes.map(([, y]) => y); const bbox = [ Math.min(...xs) - CONTOUR_PAD_M, Math.min(...ys) - CONTOUR_PAD_M, Math.max(...xs) + CONTOUR_PAD_M, Math.max(...ys) + CONTOUR_PAD_M, ].map((v) => v.toFixed(0)); try { const collection = await fetchCachedSheetLayer( projectId, DRAINAGE_SHEET_LAYERS[0], "route", bbox.join(","), ); contours = prepareLayer(collection, createNormalizer(meta), ["등고수치"]); } catch { contours = null; } } map.update({ meta, image, contours }); } async function loadSurfaces(): Promise { try { surfaces = await fetchYearSurfaces(projectId); } catch (error) { showToast(`지형면을 읽지 못했습니다 — ${errorText(error)}`, "error"); } } async function loadAnalysis(): Promise { try { analysis = await fetchPlanAnalysis(projectId, sentPoints()); startM = analysis.start_chainage_m; targetM = analysis.end_chainage_target_m ?? null; } catch (error) { analysis = null; showToast(`분석 저장본을 읽지 못했습니다 — ${errorText(error)}`, "error"); } } /** 뒤에서 도는 일을 끝날 때까지 지켜봄 — 진행은 지형 칸에. */ async function watch(kind: "terrain" | "analyze"): Promise { busy = kind; bar.hidden = false; refresh(); try { for (;;) { if (!root.isConnected) return null; const job = (await fetchPlanProgress(projectId))[kind]; bar.value = job.percent; terrainText.textContent = `${kind === "terrain" ? "지형 받기" : "분석"} ${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 = null; bar.hidden = true; refresh(); } } async function run(kind: "terrain" | "analyze"): Promise { try { await startPlanJob(projectId, kind, sentPoints()); } catch (error) { // 이미 도는 일(다른 창 · D02 [>])이면 그것을 지켜봄 if (!/이미 돌고/.test(errorText(error))) { showToast(errorText(error), "error"); return null; } } return watch(kind); } async function download(): Promise { const job = await run("terrain"); if (!job || !root.isConnected) return; await Promise.all([loadSurfaces(), loadBackground()]); refresh(); } async function analyze(): Promise { const job = await run("analyze"); if (!job || !root.isConnected) return; await loadAnalysis(); refresh(); } async function save(): Promise { const picked = Object.fromEntries( rows.filter((row) => row.mode).map((row) => [row.year, row.mode as string]), ); showLoadingOverlay(); try { await savePlanAnalysis(projectId, picked, sentPoints()); clearState("d03-plan", projectId); await loadPoints(); await loadAnalysis(); refresh(); showToast("예정노선을 저장했습니다.", "success"); } catch (error) { showToast(`저장하지 못했습니다 — ${errorText(error)}`, "error"); } finally { hideLoadingOverlay(); } } function open3d(): void { const items = rows.flatMap((row, i) => { const surface = surfaces.get(row.year); const line = row.mode ? results[i].routes[row.mode].planned : undefined; if (!surface || !line) return []; const stations = stationChainages([row], PLAN_STATION_INTERVAL_M).map((chainage) => ({ chainage, localM: chainage - row.start_chainage_m, })); return [{ year: row.year, modelId: surface.modelId, grid: surface.grid, line, stations }]; }); const first = selected !== null ? rowAt(rows, selected)?.year : undefined; openPlan3d(projectId, items, first ?? items[0]?.year ?? 0); } async function loadPoints(): Promise { const view = await fetchPlanPoints(projectId); const empty = view.years.find((entry) => !entry.start || !entry.end); if (empty) { terrainText.textContent = `${empty.year}년 지점이 비었습니다 — 1단계(기준 지정)에서 정하세요.`; return false; } years = view.years.map((entry) => entry.year); savedNodes = nodesOf( view.years.map((entry) => ({ start: [entry.start!.x, entry.start!.y] as Vertex, end: [entry.end!.x, entry.end!.y] as Vertex, })), ); const draft = readState("d03-plan", projectId); nodes = draft?.nodes?.length === savedNodes.length ? draft.nodes : savedNodes; choices = draft?.choices ?? {}; return true; } // 검증 창구 — 측점 화면 자리 · 지금 누가 · 마디(ORCA 판정) (window as unknown as { __d03Plan?: unknown }).__d03Plan = { stationScreen: map.stationScreen, rows: () => rows, nodes: () => nodes, pick: onStation, cross: () => lastCross, }; try { editable = !!(await fetchSessionUser())?.is_forest_service; if (!(await loadPoints())) return; await Promise.all([loadSurfaces(), loadAnalysis()]); refresh(); map.fit(); await loadBackground(); const progress = await fetchPlanProgress(projectId); if (progress.analyze.running) await analyze(); if (progress.terrain.running) await download(); else if (editable && reach().some((ok) => !ok)) await download(); } catch (error) { terrainText.textContent = `불러오지 못했습니다 — ${errorText(error)}`; } }