/* ============================================================================= * D03_PlanAnalysis_UI_Page.ts * D 예정노선 2단계 분석 화면(요구서 345 3-3 · PLAN 530-3) — B05~B09 틀(`mountGroupStep`). * * 메인 = 평면(위성 + 도엽 등고선 + 지점 + 연도 구간 노선 · 측점) · 하 = 누른 측점 지반선 횡단 · * 좌 = 지형 받기 진행 · 연도별 갈래 넷 카드 · [분석] [저장] [3D]. * 열리면 지형 받기(뒤에서 · 받은 지형 안이면 건너뜀) · 끈 지점이 받은 범위 밖 = 「범위 밖 — 더 받기」. * * 데이터 3층(CLAUDE.md 5장) — 지점 끌기 · 갈래 고름 · 꺾임점 고침(543) = 캐시(`d03-plan`) · [저장] = * PUT plan/analysis(끈 지점은 서버가 D02 규칙으로 정본에 씀 · 노선은 서버 저장본으로 · 고친 노선은 * 마디로 서버가 다시 셈). 꺾임점 고침 = 브라우저 즉시 다시 그음 → 길이 · 측점 · 누른 측점 횡단 · * 되돌리기(Ctrl+Z). 화면 전체 잠금 = [저장]만. * 공유 회사(산림청 아님) = 보기만 — 끌기 · [분석] · [저장] · 받기 없음. * ========================================================================== */ 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 SavedEdit, type PlanJob, type YearSurface, } from "./D03_PlanAnalysis_Api"; import { openPlan3d } from "./D03_PlanAnalysis_UI_3D"; import { createPlanCross } from "./D03_PlanAnalysis_UI_Cross"; import { createPlanEditLayer, type EditTarget } from "./D03_PlanAnalysis_UI_Edit"; import { createPlanMap, PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map"; import { chainRows, joinedLine, nodesInReach, nodesOf, PLAN_MODES, pointsOf, rowAt, sameNodes, stationChainages, withEdits, type ChainRow, type RouteEdit, type Vertex, type YearAnalysis, } from "./logic/D03_PlanAnalysis_Plan"; import { buildEdit, editFromRoute, moveNode, sameEdit, type PlanEdit, } from "./logic/D03_PlanAnalysis_Edit"; const MODE_LABEL: Record = { gradeline: "영선", earthwork: "토공 최소", gentle: "기울기 최소", weighted: "비중 계산", }; const POLL_MS = 1000; /** 등고선을 받아 올 지점 둘레(m). */ const CONTOUR_PAD_M = 600; /** 되돌리기 쌓는 수. */ const UNDO_MAX = 50; interface Draft { nodes: Vertex[] | null; choices: Record; /** 꺾임점 고침(연도 → 고침) — 있으면 이것이 지금 값(서버 저장 고침보다 먼저). */ edits?: Record; } type Snapshot = { edits: Record; 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 editLayer = createPlanEditLayer({ begin: remember, change: onEdit, blocked: (message) => showToast(message, "info"), }); const map = createPlanMap({ onDrag, onDragEnd, onStation, overlay: editLayer }); 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 undoButton = createButton({ label: "되돌리기", variant: "ghost", onClick: () => undo() }); undoButton.title = "꺾임점 고침 · 갈래 고름 한 걸음 되돌림(Ctrl+Z)"; const actions = document.createElement("div"); actions.className = "ui-sidebar-actions"; actions.append(analyzeButton, saveButton, viewButton, undoButton); 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; // 꺾임점 고침 — 지금 값 · 서버 저장 고침 · 캐시에서 읽은 값(없으면 서버 값으로) · 되돌리기 let edits: Record = {}; let serverEdits: Record = {}; let draftEdits: Record | undefined; let history: Snapshot[] = []; let lastSaveMs: number | 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, edits } satisfies Draft, projectId, ); /** 고치기 직전 — 되돌리기에 지금 값을 쌓음. */ function remember(): void { history = [...history.slice(1 - UNDO_MAX), structuredClone({ edits, choices })]; } function undo(): void { const last = history.pop(); if (!last) return; ({ edits, choices } = last); keepDraft(); refresh(); repick(); } /** 누른 측점 횡단을 지금 노선으로 다시. */ function repick(): void { if (selected !== null && rowAt(rows, selected)) onStation(selected); } function onEdit(year: number, edit: PlanEdit, final: boolean): void { edits = { ...edits, [year]: edit }; refresh(); if (!final) return; keepDraft(); repick(); } /** 연도의 지금 고침 — 고른 갈래 · 분석 지문이 같을 때만. */ const editOf = (result: YearAnalysis, mode: string | null): PlanEdit | null => { const own = edits[result.year]; return own && own.base_mode === mode && own.base_fingerprint === result.fingerprint ? own : null; }; /** 연도 목록에서 뺌. */ const without = (from: Record, drop: number[]): Record => Object.fromEntries(Object.entries(from).filter(([year]) => !drop.includes(Number(year)))); /** 저장본 · 고름 → 이은 누가 · 중심선 · 측점 → 평면 · 카드 · 단추. */ function refresh(): void { const raw: YearAnalysis[] = 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])); const picks = { ...saved, ...choices }; // 꺾임점 고침 — 고른 갈래 노선의 마디(고침 · 없으면 엔진 노선) → 선 → 그 갈래 자리에 갈아 끼움 const targets: EditTarget[] = []; const routed: Record = {}; chainRows(raw, picks, startM).forEach((row, i) => { const result = raw[i]; const route = row.mode ? result.routes[row.mode] : undefined; if (!row.mode || !route?.nodes || !result.fingerprint) return; const minRadiusM = result.criteria?.min_radius_m ?? 0; const own = editOf(result, row.mode); const built = buildEdit( own ?? editFromRoute(route, row.mode, result.fingerprint), minRadiusM, ); targets.push({ year: result.year, ...built, minRadiusM }); if (own) routed[result.year] = { mode: row.mode, planned: built.line.vertices, metrics: sameEdit(own, serverEdits[result.year]) ? serverEdits[result.year].metrics : null, }; }); editLayer.setTargets(editable ? targets : []); results = withEdits(raw, routed); rows = chainRows(results, picks, 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)); undoButton.hidden = !editable; undoButton.disabled = !history.length; } 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); if (row.mode && editOf(result, row.mode)) box.append(editLine(result.year)); 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.max_grade_pct === undefined ? `연장 ${metrics.length_m.toFixed(1)} m · 기울기 · 위반 = 저장 때 셈` : `연장 ${metrics.length_m.toFixed(1)} m · 최대 ${metrics.max_grade_pct.toFixed(1)} % · 위반 ${metrics.over_limit?.total ?? 0}`; card.append(name, values); card.addEventListener("click", () => { if (row.mode === mode) return; remember(); choices = { ...choices, [result.year]: mode }; keepDraft(); refresh(); }); box.append(card); } return box; }), ...targetLine(), ); } /** 고친 연도 한 줄 — 「꺾임점 고침」 · [원래 노선](고침 버림 · 되돌리기 됨). */ function editLine(year: number): HTMLElement { const line = document.createElement("div"); line.className = "d03-plan__year-head"; line.textContent = sameEdit(edits[year], serverEdits[year]) ? "꺾임점 고침(저장됨) " : "꺾임점 고침 — [저장] 전 "; const reset = createButton({ label: "원래 노선", variant: "ghost", size: "sm", onClick: () => { remember(); edits = without(edits, [year]); keepDraft(); refresh(); repick(); }, }); reset.hidden = !editable; line.append(reset); return line; } /** 이은 끝 누가 대 등록 목표(요구서 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 { // 끈 지점의 앞 · 뒤 연도 노선은 지점이 달라 빔 — 다시 [분석] · 그 연도 꺾임점 고침도 버림 const lost = [years[index - 1], years[index]].filter( (year) => year !== undefined && edits[year], ); if (lost.length) { edits = without(edits, lost); history = []; showToast(`지점을 옮겨 ${lost.join(" · ")}년 꺾임점 고침을 버렸습니다.`, "info"); } 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; serverEdits = Object.fromEntries( analysis.years.flatMap((entry) => (entry.edit ? [[entry.year, entry.edit]] : [])), ); edits = draftEdits ?? Object.fromEntries( Object.entries(serverEdits).map(([year, { metrics: _m, ...edit }]) => [year, edit]), ); // 분석이 바뀐 연도(지문 다름)의 고침은 버림 const prints = new Map(analysis.years.map((e) => [e.year, e.analysis?.fingerprint])); const stale = Object.keys(edits) .map(Number) .filter((year) => prints.get(year) !== edits[year].base_fingerprint); if (stale.length) { edits = without(edits, stale); if (draftEdits) showToast(`분석이 바뀌어 ${stale.join(" · ")}년 꺾임점 고침을 버렸습니다.`, "info"); } draftEdits = edits; } 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]), ); const sent = Object.fromEntries( results.flatMap((result, i) => { const edit = editOf(result, rows[i].mode); return edit ? [[result.year, edit]] : []; }), ); showLoadingOverlay(); try { const began = performance.now(); await savePlanAnalysis(projectId, picked, sentPoints(), sent); lastSaveMs = Math.round(performance.now() - began); clearState("d03-plan", projectId); draftEdits = undefined; history = []; 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 ?? {}; draftEdits = draft?.edits; return true; } // 검증 창구 — 측점 화면 자리 · 지금 누가 · 마디 · 꺾임점 고침 · 저장 시간(ORCA 판정) (window as unknown as { __d03Plan?: unknown }).__d03Plan = { stationScreen: map.stationScreen, screenOf: map.screenOf, rows: () => rows, nodes: () => nodes, pick: onStation, cross: () => lastCross, edits: () => edits, targets: () => editLayer.targets(), results: () => results, saveMs: () => lastSaveMs, // 가이드 그림 · 검증 — 첫 고침 연도 마디 하나를 (dx, dy) m 옮김(끌기와 같은 길) nudge: (index: number, dx: number, dy: number) => { const target = editLayer.targets()[0]; const node = target?.edit.nodes[index]; if (!node) return; const step = moveNode(target.edit, index, [node[0] + dx, node[1] + dy], target.minRadiusM); if ("blocked" in step) return showToast(step.blocked, "info"); remember(); onEdit(target.year, step.edit, true); }, }; 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 (editable && !typing && (event.ctrlKey || event.metaKey) && event.key === "z") { event.preventDefault(); undo(); } }; window.addEventListener("keydown", onKey); 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)}`; } }