From d93bd1cba8aaa25fc71b395fdb196ae64c4b8596 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 2 Sep 2026 16:48:01 +0900 Subject: [PATCH] =?UTF-8?q?refactor(B05):=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EB=8F=99=EC=9E=91=203=EC=A2=85=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20(819=E2=86=92679=EC=A4=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `B05_Profile_UI_Page_Actions.ts`(216줄) 신설 — [경로 계산]·[임시저장]·[초기화]. 페이지는 상태 창구(`PageActionContext`)만 넘기고 화면 조립·로딩 순서만 맡음. - 화면 검증: B05 [저장] 실제 클릭 — `PUT /drainage/pipe-points` 200 → `POST /route/confirm?mark_stage_complete=false` 200 → `GET /route/latest` 200 → `PUT /routes/111/corridor` 200 순서 그대로, 콘솔 오류 0. (앞서 분리한 Lifecycle 라우터의 `/route/confirm` 도 이 경로로 함께 확인됨.) - `tsc --noEmit` 0, prettier 적용, pytest 359 passed. --- B05_Profile/B05_Profile_UI_Page.ts | 194 +++--------------- B05_Profile/B05_Profile_UI_Page_Actions.ts | 216 +++++++++++++++++++++ 2 files changed, 243 insertions(+), 167 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Page_Actions.ts diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index b99820a1..6ffbde49 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -1,11 +1,6 @@ import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; -import { - hideLoadingOverlay, - showConfirmDialog, - showLoadingOverlay, - showToast, -} from "@ui/ui_template_elements"; +import { showToast } from "@ui/ui_template_elements"; import { purgeOtherProjects } from "../A00_Common/b_asset_cache"; import { createProgressCircle } from "@ui/ui_template_progress"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; @@ -21,13 +16,9 @@ import { type SurfaceModelSummary, } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { - clearRouteLatestCache, - confirmRoute, fetchLatestRoute, readRouteLatestCache, - resetRouteDesign, writeRouteLatestCache, - solveRoute, updateContourInterval, type RouteLatestResponse, } from "./B05_Profile_Api_Fetch"; @@ -43,24 +34,22 @@ import { fetchSectionContext, type SectionDetailResponse, } from "../B06_Section/B06_Section_Api_Fetch"; -import { flushCulvertOptions } from "../B06_Section/B06_Section_Api_Culvert_Options"; -import { - invalidateSectionDetail, - loadSectionDetail, - saveCachedCrossPatches, -} from "../B06_Section/B06_Section_Section_Store"; -import { clearStandardCrossSession } from "../B06_Section/B06_Section_UI_Standard_Panel"; +import { loadSectionDetail } from "../B06_Section/B06_Section_Section_Store"; import { migrateLegacyStations } from "./B05_Profile_Api_Structures"; import { refreshCorridor, saveCorridorIfDirty } from "./B05_Profile_UI_Corridor"; +import { + resetDesignAction, + solveRouteAction, + tempSaveAction, + type PageActionContext, +} from "./B05_Profile_UI_Page_Actions"; import "./B05_Profile_UI_Style.css"; import "./B05_Profile_UI_Style_Structures.css"; import { - circlePoint, DEFAULT_ROAD_WIDTHS, fetchRoadWidths, interpolateIrregularStations, restorePoints, - routePoint, toBounds, toGradeClass, } from "./B05_Profile_UI_Page_Helpers"; @@ -241,14 +230,14 @@ export async function renderB05Route(root: HTMLElement): Promise { } const panel = createRoutePanel({ - onSolve: () => void solve(), - onTempSave: () => void tempSave(), + onSolve: () => void solveRouteAction(actionContext), + onTempSave: () => void tempSaveAction(actionContext), onGoCross: () => { // 페이지 이동 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다. if (latest?.route?.id) void saveCorridorIfDirty(activeProjectId, latest.route.id); navigateTo(ROUTES.B06_SECTION); }, - onReset: () => void resetDesign(), + onReset: () => void resetDesignAction(actionContext), onContourApply: (interval) => applyContours(interval), onSurfaceVisible: viewer.setSurfaceVisible, onCorridorVisible: (visible) => { @@ -565,151 +554,22 @@ export async function renderB05Route(root: HTMLElement): Promise { } } - async function solve(): Promise { - if (!confirmedSurface || !latest) return; - const points = viewer.markers.getPoints(); - if (!points.bp || !points.ep) { - showToast("BP와 EP를 지형에 배치하세요.", "error"); - return; - } - const values = panel.values(); - showLoadingOverlay(); - try { - const solved = await solveRoute(activeProjectId, { - filter_key: latest.surface_params.source_filter, - method: latest.surface_params.method, - smooth: latest.surface_params.smooth, - surface_model_id: confirmedSurface.id, - algorithm: values.algorithm, - bp: routePoint(points.bp), - ep: routePoint(points.ep), - cp: points.cp.map(routePoint), - ap: points.ap.map(circlePoint), - fp: points.fp.map(circlePoint), - grade_class: values.gradeClass, - paved: values.paved, - min_curve_radius_m: values.minCurveRadius, - max_uphill_grade: values.maxUphillGrade, - max_downhill_grade: values.maxDownhillGrade, - min_uphill_grade: values.minUphillGrade, - min_downhill_grade: values.minDownhillGrade, - allow_avoid_pass_through: values.allowAvoidPassThrough, - station_interval_m: values.stationInterval, - cross_half_width_m: null, - cross_sample_interval_m: values.crossSampleInterval, - long_sample_interval_m: values.longSampleInterval, - terrain_type: values.terrainType, - design_speed_kph: values.designSpeed, - max_grade_pct: values.maxGradePct, - min_vertical_radius_m: values.minVerticalRadius, - min_tangent_length_m: values.minTangentLength, - start_elevation_offset_m: values.startElevationOffset, - end_elevation_offset_m: values.endElevationOffset, - enforce_pipe_clearance: values.enforcePipeClearance, - }); - // 새 경로는 측점 구성이 달라지므로 이전 상단측 변경분을 폐기한다(자동 판정 재사용). - uphillOverrides.clear(); - persistUphillOverrides(); - renderLatest(await loadLatest(true)); - await restoreSections(solved.route_id); - if (solved.cross_section_count === null) { - showToast("경로는 저장되었지만 종횡단 생성에 실패했습니다.", "error"); - } else if (solved.grade_summary === null) { - showToast("경로·종횡단은 저장되었지만 계획선 산출에 실패했습니다.", "error"); - } else { - showToast("최적 경로 계산이 완료되었습니다.", "success"); - } - } catch (error) { - showToast(error instanceof Error ? error.message : "경로 계산에 실패했습니다.", "error"); - } finally { - hideLoadingOverlay(); - } - } - - /** [임시저장] — 현재 편집을 영구저장소에 남기되 워크플로 단계·페이지는 그대로 둔다. - * 종·횡 통합 확정은 B06 [확정]이 담당한다(2026-08-08 워크플로우 재정의). */ - async function tempSave(): Promise { - if (!routeReady) return; - // 관 매설 지점을 B04와 같은 저장소에 남긴다 — 저장 실패가 임시저장을 막지는 않는다. - await profilePanel.drainage.savePipes().catch(() => 0); - showLoadingOverlay(); - try { - // 종단 계획선 편집은 화면에서만 계산해 두었으므로 저장 시점에 영속화한다. - await profilePanel.save(); - // 구조물도 조작분이 세션에만 있다(2026-08-29 — CLAUDE.md 5장). 실패는 자체 토스트로 - // 알리고 세션에 남겨 두므로, 여기서 저장 전체를 멈추지 않는다. - await bridge.saveStructuresIfDirty(); - // 비정규 측점·상단측 변경분까지 데이터로는 확정 저장하되, 단계 완료 전이는 하지 않는다. - await confirmRoute( - activeProjectId, - { - filter_key: latest?.surface_params.source_filter, - method: latest?.surface_params.method, - smooth: latest?.surface_params.smooth, - surface_model_id: confirmedSurface?.id, - irregular_stations: bridge.irregularStations().map((station) => ({ - chainage_m: station.chainage_m, - structure: station.structure, - })), - // 상단측(측구 방향) 사용자 변경분 — 종단 정본에 병합되어 B06이 그대로 소비한다. - uphill_overrides: [...uphillOverrides.entries()].map(([chainage, side]) => ({ - chainage_m: Number(chainage), - side, - })), - }, - false, - ); - // B06 조정창에서 만진 배수관 구간값도 세션에만 있다 — 함께 내보낸다 - // (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만). 실패해도 저장은 진행한다. - if (latest?.route?.id != null) { - await flushCulvertOptions( - activeProjectId, - `b06:culvertopt:${activeProjectId}:${latest.route.id}`, - ).catch(() => undefined); - } - // B06에서 만져 **캐시에 얹힌** 횡단 수정분을 함께 남긴다 — 안 보내면 바로 아래 - // 캐시 비우기에서 사라진다. 계획선 저장 **뒤에** 보내야 사용자 수정 1세트가 - // 최종본으로 얹힌다(2026-08-24 사용자 지적). - if (latest?.route?.id != null) { - await saveCachedCrossPatches(activeProjectId, latest.route.id); - } - // 서버가 종단 정본의 계획선을 다시 썼다 — 공유 캐시를 비워 B06이 옛 계획선을 못 보게 한다. - invalidateSectionDetail(activeProjectId); - renderLatest(await loadLatest(true)); - // 임시저장 = 코리도 영구저장 시점(2026-08-23) — 실패해도 임시저장은 성공 처리. - if (latest?.route?.id) void saveCorridorIfDirty(activeProjectId, latest.route.id); - showToast(L("B05_Route_TempSave_Success"), "success"); - } catch (error) { - showToast(error instanceof Error ? error.message : L("B05_Route_TempSave_Failed"), "error"); - } finally { - hideLoadingOverlay(); - } - } - - /** [초기화] — 사용자 편집 전부 폐기, 계획노선 CSV 기본값으로 B05·B06 재계산 후 재진입. - * 네이티브 confirm은 공용 헤드 브라우저가 자동 취소해 버튼이 죽은 듯 보였다 - * (2026-08-19 진단) — 화면 안 모달로 확인받는다. */ - async function resetDesign(): Promise { - if (!(await showConfirmDialog(L("B05_Route_Reset_Confirm"), "초기화"))) return; - showLoadingOverlay(); - try { - await resetRouteDesign(activeProjectId); - // 옛 경로 기준 캐시를 전부 비우고 페이지를 새로 그린다 — 초기 계산본이 정본이 된다. - clearRouteLatestCache(activeProjectId); - invalidateSectionDetail(activeProjectId); - // 프로젝트 단위 세션 값도 함께 버린다 — 키에 route_id가 없어 새 노선에 그대로 - // 되붙는다(2026-08-28). 초기화는 "사용자 편집을 전부 버린다"가 규약이다. - uphillOverrides.clear(); - persistUphillOverrides(); - clearStandardCrossSession(activeProjectId); - showToast(L("B05_Route_Reset_Success"), "success"); - navigateTo(ROUTES.B05_PROFILE); - } catch (error) { - showToast(error instanceof Error ? error.message : L("B05_Route_Reset_Failed"), "error"); - } finally { - hideLoadingOverlay(); - } - } + // 버튼 동작 3종([경로 계산]·[임시저장]·[초기화])은 따로 뗀 모듈이 맡는다(2026-09-02). + const actionContext: PageActionContext = { + projectId: activeProjectId, + latest: () => latest, + confirmedSurface: () => confirmedSurface, + routeReady: () => routeReady, + viewer: () => viewer, + panel: () => panel, + profilePanel: () => profilePanel, + bridge: () => bridge, + uphillOverrides, + persistUphillOverrides, + loadLatest, + renderLatest, + restoreSections, + }; /* ── 진입 로딩 ───────────────────────────────────────────────────────── * 전부 받아 놓고 한 번에 그리면 몇 초 동안 빈 화면만 보인다. 화면 틀을 먼저 띄우고 diff --git a/B05_Profile/B05_Profile_UI_Page_Actions.ts b/B05_Profile/B05_Profile_UI_Page_Actions.ts new file mode 100644 index 00000000..d54c74d5 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Page_Actions.ts @@ -0,0 +1,216 @@ +/* ============================================================================= + * B05_Profile_UI_Page_Actions.ts + * B05 페이지의 **버튼 동작** — [경로 계산]·[임시저장]·[초기화]. + * + * `B05_Profile_UI_Page` 에서 떼어냈다(700줄 제한, 2026-09-02). 화면 조립·로딩 순서는 + * 페이지에 남고, 여기에는 영구저장소를 건드리는 세 갈래만 둔다(CLAUDE.md 5장: + * 세션에 쌓인 조작은 [저장]·[확정]에서만 정본으로 나간다). + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import { + hideLoadingOverlay, + showConfirmDialog, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { navigateTo } from "../A00_Common/router"; +import type { SurfaceModelSummary } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { + clearRouteLatestCache, + confirmRoute, + resetRouteDesign, + solveRoute, + type RouteLatestResponse, +} from "./B05_Profile_Api_Fetch"; +import { flushCulvertOptions } from "../B06_Section/B06_Section_Api_Culvert_Options"; +import { + invalidateSectionDetail, + saveCachedCrossPatches, +} from "../B06_Section/B06_Section_Section_Store"; +import { clearStandardCrossSession } from "../B06_Section/B06_Section_UI_Standard_Panel"; +import { saveCorridorIfDirty } from "./B05_Profile_UI_Corridor"; +import { circlePoint, routePoint } from "./B05_Profile_UI_Page_Helpers"; +import type { createRoutePanel } from "./B05_Profile_UI_Panel"; +import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel"; +import type { createStructuresBridge } from "./B05_Profile_UI_Page_Structures"; +import type { createRouteViewer } from "./B05_Profile_UI_Viewer"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +/** 버튼 동작이 페이지에서 받아 쓰는 창구 — 값은 바뀌므로 함수로 받는다. */ +export interface PageActionContext { + projectId: string; + latest: () => RouteLatestResponse | null; + confirmedSurface: () => SurfaceModelSummary | null; + routeReady: () => boolean; + viewer: () => ReturnType; + panel: () => ReturnType; + profilePanel: () => ReturnType; + bridge: () => ReturnType; + /** 상단측(측구 방향) 사용자 변경분 — 키는 누가거리 문자열. */ + uphillOverrides: Map; + persistUphillOverrides: () => void; + loadLatest: (forceFresh?: boolean) => Promise; + renderLatest: (next: RouteLatestResponse) => void; + restoreSections: (routeId: number) => Promise; +} + +/** [경로 계산] — 마커·패널 값으로 노선을 풀고 종횡단까지 다시 받는다. */ +export async function solveRouteAction(ctx: PageActionContext): Promise { + const latest = ctx.latest(); + const confirmedSurface = ctx.confirmedSurface(); + if (!confirmedSurface || !latest) return; + const points = ctx.viewer().markers.getPoints(); + if (!points.bp || !points.ep) { + showToast("BP와 EP를 지형에 배치하세요.", "error"); + return; + } + const values = ctx.panel().values(); + showLoadingOverlay(); + try { + const solved = await solveRoute(ctx.projectId, { + filter_key: latest.surface_params.source_filter, + method: latest.surface_params.method, + smooth: latest.surface_params.smooth, + surface_model_id: confirmedSurface.id, + algorithm: values.algorithm, + bp: routePoint(points.bp), + ep: routePoint(points.ep), + cp: points.cp.map(routePoint), + ap: points.ap.map(circlePoint), + fp: points.fp.map(circlePoint), + grade_class: values.gradeClass, + paved: values.paved, + min_curve_radius_m: values.minCurveRadius, + max_uphill_grade: values.maxUphillGrade, + max_downhill_grade: values.maxDownhillGrade, + min_uphill_grade: values.minUphillGrade, + min_downhill_grade: values.minDownhillGrade, + allow_avoid_pass_through: values.allowAvoidPassThrough, + station_interval_m: values.stationInterval, + cross_half_width_m: null, + cross_sample_interval_m: values.crossSampleInterval, + long_sample_interval_m: values.longSampleInterval, + terrain_type: values.terrainType, + design_speed_kph: values.designSpeed, + max_grade_pct: values.maxGradePct, + min_vertical_radius_m: values.minVerticalRadius, + min_tangent_length_m: values.minTangentLength, + start_elevation_offset_m: values.startElevationOffset, + end_elevation_offset_m: values.endElevationOffset, + enforce_pipe_clearance: values.enforcePipeClearance, + }); + // 새 경로는 측점 구성이 달라지므로 이전 상단측 변경분을 폐기한다(자동 판정 재사용). + ctx.uphillOverrides.clear(); + ctx.persistUphillOverrides(); + ctx.renderLatest(await ctx.loadLatest(true)); + await ctx.restoreSections(solved.route_id); + if (solved.cross_section_count === null) { + showToast("경로는 저장되었지만 종횡단 생성에 실패했습니다.", "error"); + } else if (solved.grade_summary === null) { + showToast("경로·종횡단은 저장되었지만 계획선 산출에 실패했습니다.", "error"); + } else { + showToast("최적 경로 계산이 완료되었습니다.", "success"); + } + } catch (error) { + showToast(error instanceof Error ? error.message : "경로 계산에 실패했습니다.", "error"); + } finally { + hideLoadingOverlay(); + } +} + +/** [임시저장] — 현재 편집을 영구저장소에 남기되 워크플로 단계·페이지는 그대로 둔다. + * 종·횡 통합 확정은 B06 [확정]이 담당한다(2026-08-08 워크플로우 재정의). */ +export async function tempSaveAction(ctx: PageActionContext): Promise { + if (!ctx.routeReady()) return; + const latest = ctx.latest(); + const projectId = ctx.projectId; + // 관 매설 지점을 B04와 같은 저장소에 남긴다 — 저장 실패가 임시저장을 막지는 않는다. + await ctx + .profilePanel() + .drainage.savePipes() + .catch(() => 0); + showLoadingOverlay(); + try { + // 종단 계획선 편집은 화면에서만 계산해 두었으므로 저장 시점에 영속화한다. + await ctx.profilePanel().save(); + // 구조물도 조작분이 세션에만 있다(2026-08-29 — CLAUDE.md 5장). 실패는 자체 토스트로 + // 알리고 세션에 남겨 두므로, 여기서 저장 전체를 멈추지 않는다. + await ctx.bridge().saveStructuresIfDirty(); + // 비정규 측점·상단측 변경분까지 데이터로는 확정 저장하되, 단계 완료 전이는 하지 않는다. + await confirmRoute( + projectId, + { + filter_key: latest?.surface_params.source_filter, + method: latest?.surface_params.method, + smooth: latest?.surface_params.smooth, + surface_model_id: ctx.confirmedSurface()?.id, + irregular_stations: ctx + .bridge() + .irregularStations() + .map((station) => ({ + chainage_m: station.chainage_m, + structure: station.structure, + })), + // 상단측(측구 방향) 사용자 변경분 — 종단 정본에 병합되어 B06이 그대로 소비한다. + uphill_overrides: [...ctx.uphillOverrides.entries()].map(([chainage, side]) => ({ + chainage_m: Number(chainage), + side, + })), + }, + false, + ); + // B06 조정창에서 만진 배수관 구간값도 세션에만 있다 — 함께 내보낸다 + // (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만). 실패해도 저장은 진행한다. + if (latest?.route?.id != null) { + await flushCulvertOptions(projectId, `b06:culvertopt:${projectId}:${latest.route.id}`).catch( + () => undefined, + ); + } + // B06에서 만져 **캐시에 얹힌** 횡단 수정분을 함께 남긴다 — 안 보내면 바로 아래 + // 캐시 비우기에서 사라진다. 계획선 저장 **뒤에** 보내야 사용자 수정 1세트가 + // 최종본으로 얹힌다(2026-08-24 사용자 지적). + if (latest?.route?.id != null) { + await saveCachedCrossPatches(projectId, latest.route.id); + } + // 서버가 종단 정본의 계획선을 다시 썼다 — 공유 캐시를 비워 B06이 옛 계획선을 못 보게 한다. + invalidateSectionDetail(projectId); + ctx.renderLatest(await ctx.loadLatest(true)); + // 임시저장 = 코리도 영구저장 시점(2026-08-23) — 실패해도 임시저장은 성공 처리. + if (latest?.route?.id) void saveCorridorIfDirty(projectId, latest.route.id); + showToast(L("B05_Route_TempSave_Success"), "success"); + } catch (error) { + showToast(error instanceof Error ? error.message : L("B05_Route_TempSave_Failed"), "error"); + } finally { + hideLoadingOverlay(); + } +} + +/** [초기화] — 사용자 편집 전부 폐기, 계획노선 CSV 기본값으로 B05·B06 재계산 후 재진입. + * 네이티브 confirm은 공용 헤드 브라우저가 자동 취소해 버튼이 죽은 듯 보였다 + * (2026-08-19 진단) — 화면 안 모달로 확인받는다. */ +export async function resetDesignAction(ctx: PageActionContext): Promise { + if (!(await showConfirmDialog(L("B05_Route_Reset_Confirm"), "초기화"))) return; + showLoadingOverlay(); + try { + await resetRouteDesign(ctx.projectId); + // 옛 경로 기준 캐시를 전부 비우고 페이지를 새로 그린다 — 초기 계산본이 정본이 된다. + clearRouteLatestCache(ctx.projectId); + invalidateSectionDetail(ctx.projectId); + // 프로젝트 단위 세션 값도 함께 버린다 — 키에 route_id가 없어 새 노선에 그대로 + // 되붙는다(2026-08-28). 초기화는 "사용자 편집을 전부 버린다"가 규약이다. + ctx.uphillOverrides.clear(); + ctx.persistUphillOverrides(); + clearStandardCrossSession(ctx.projectId); + showToast(L("B05_Route_Reset_Success"), "success"); + navigateTo(ROUTES.B05_PROFILE); + } catch (error) { + showToast(error instanceof Error ? error.message : L("B05_Route_Reset_Failed"), "error"); + } finally { + hideLoadingOverlay(); + } +}