From fb5ab57c7417beee582fb20cb7ebf4ddc5a2b78c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 12:35:13 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor(b05):=20=EC=A2=8C=EC=B8=A1=20?= =?UTF-8?q?=ED=8C=A8=EB=84=90=20=E3=80=8C=EA=B2=BD=EB=A1=9C=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EC=84=A4=EC=A0=95=E3=80=8D=20=EC=83=81=EC=9E=90=20?= =?UTF-8?q?Old=20=EB=A1=9C=20=EC=98=AE=EA=B9=80(54-24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상자 화면(포인트 팔레트 · 알고리즘 · 조건 · 숨긴 [최적 경로 계산]) · solveRouteAction · routePoint/circlePoint 를 B05_Profile/Old/B05_Profile_UI_RouteCalc.ts 로 떼어 옮김 - 좌측 패널 등록 · onSolve · 칸 값(algorithm · paved · 곡선반경 · 경사 상하한 · 회피 통과) 끊음 - tsconfig 에서 B05_Profile/Old 제외 · 서버 경로 계산 API · 자동설계 체인은 그대로 - 시험: 좌측 칸 수 1+4+2 · 칸 제목에서 「경로 계산 설정」 뺌 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- A00_Common/design/design_view_profile.ts | 2 - B05_Profile/B05_Profile_UI_Page_Actions.ts | 70 +------ B05_Profile/B05_Profile_UI_Page_Helpers.ts | 9 - B05_Profile/B05_Profile_UI_Page_Latest.ts | 8 - B05_Profile/B05_Profile_UI_Panel.ts | 36 ---- B05_Profile/B05_Profile_UI_Panel_Fields.ts | 8 - B05_Profile/B05_Profile_UI_Panel_Route.ts | 80 +------- B05_Profile/B05_Profile_UI_Panel_Sections.ts | 23 +-- B05_Profile/Old/B05_Profile_UI_RouteCalc.ts | 192 ++++++++++++++++++ .../tester/helper_design_view_parity.cjs | 1 - resources/tester/test_design_view_parity.py | 3 +- tsconfig.json | 2 +- 12 files changed, 203 insertions(+), 231 deletions(-) create mode 100644 B05_Profile/Old/B05_Profile_UI_RouteCalc.ts diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 39413fa5d..e9614496a 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -65,7 +65,6 @@ import { } from "../../B05_Profile/B05_Profile_UI_Corridor"; import { profileSaveParts, - solveRouteAction, type PageActionContext, } from "../../B05_Profile/B05_Profile_UI_Page_Actions"; import { createLatestSource, panelValuesOf } from "../../B05_Profile/B05_Profile_UI_Page_Latest"; @@ -238,7 +237,6 @@ export async function mountProfileView( const panel = createRoutePanel( { - onSolve: () => void solveRouteAction(actionContext), // 계획노선 편집 — 모달 [확인]에서 서버가 배수유역부터 다시 계산하므로, 끝나면 // 옛 노선 기준 캐시를 버리고 페이지를 새로 세운다([초기화]와 같은 뒷정리). onEditPlannedRoute: () => diff --git a/B05_Profile/B05_Profile_UI_Page_Actions.ts b/B05_Profile/B05_Profile_UI_Page_Actions.ts index 74cfd550f..5d6fc7ef8 100644 --- a/B05_Profile/B05_Profile_UI_Page_Actions.ts +++ b/B05_Profile/B05_Profile_UI_Page_Actions.ts @@ -1,21 +1,19 @@ /* ============================================================================= * B05_Profile_UI_Page_Actions.ts - * 종단 보기의 **버튼 동작** — [경로 계산] · [저장]·[확정]에 보태는 종단 보기 몫. + * 종단 보기의 **버튼 동작** — [저장]·[확정]에 보태는 종단 보기 몫. [경로 계산]은 `Old/` 로 옮김(54-24). * * 종단 보기 본체(`design_view_profile`)에서 떼어냈다(700줄 제한, 2026-09-02). 화면 조립·로딩 * 순서는 본체에 남고, 여기에는 영구저장소를 건드리는 갈래만 둔다(CLAUDE.md 5장: 세션에 쌓인 * 조작은 [저장]·[확정]에서만 정본으로 나간다). [초기화]는 한 벌(`design_reset`)이 한다. * ========================================================================== */ -import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; -import { confirmRoute, solveRoute, type RouteLatestResponse } from "./B05_Profile_Api_Fetch"; +import { confirmRoute, type RouteLatestResponse } from "./B05_Profile_Api_Fetch"; import type { DesignViewSave } from "../A00_Common/design/design_shared"; import { invalidateSectionDetail, saveCachedCrossPatches, } from "../B06_Section/B06_Section_Section_Store"; 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"; @@ -40,70 +38,6 @@ export interface PageActionContext { 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.model_id ?? undefined, - 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(); - } -} - /** [저장] · [확정]에 종단 보기가 보태는 몫 — 차례는 저장 한 줄기(`saveDesign`)가 정하고 * 한 벌(`design_shared`)이 두 보기 몫을 모아 한 번에 보낸다(계획서 24-2a · 24-3b). 관(배수유역도) · * 계획고는 한 벌 종단도가 낸다. */ diff --git a/B05_Profile/B05_Profile_UI_Page_Helpers.ts b/B05_Profile/B05_Profile_UI_Page_Helpers.ts index b0b158bb9..a7d00fefa 100644 --- a/B05_Profile/B05_Profile_UI_Page_Helpers.ts +++ b/B05_Profile/B05_Profile_UI_Page_Helpers.ts @@ -90,15 +90,6 @@ export function restorePoints(latest: RouteLatestResponse): RouteDesignPoints { }; } -export function routePoint(point: PlacedRoutePoint): RoutePoint { - // 서버 스키마는 표고를 빼면 "모름"으로 받는다 — null은 undefined로 바꿔 보낸다. - return { x: point.x, y: point.y, z: point.z ?? undefined }; -} - -export function circlePoint(point: PlacedRoutePoint): CirclePoint { - return { ...routePoint(point), radius_m: point.radius_m ?? 25 }; -} - /** 노선 폴리라인 위 chainage 지점의 (x, y) — 세그먼트 누적 길이로 정확히 되짚는다. * 규칙 측점(20m 간격) 사이 직선보간은 곡선 구간에서 모서리를 잘라 3D 마커가 노선을 * 벗어난다(2026-08-19 사용자 보고 — 도엽 등고선 분석 좌표와 라이다 노선의 미세 차). diff --git a/B05_Profile/B05_Profile_UI_Page_Latest.ts b/B05_Profile/B05_Profile_UI_Page_Latest.ts index b8698269e..186b828c0 100644 --- a/B05_Profile/B05_Profile_UI_Page_Latest.ts +++ b/B05_Profile/B05_Profile_UI_Page_Latest.ts @@ -68,15 +68,7 @@ export function panelValuesOf(next: RouteLatestResponse): Partial void; /** [계획노선 편집] — 큰 모달을 열어 노선을 고친다(계산은 모달 [확인]에서만 돈다). */ onEditPlannedRoute: () => void; onContourApply: (interval: number) => void | Promise; @@ -78,7 +77,6 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh const { viewControls, setCorridorPending } = createViewControls(callbacks); const settings = createSettingsPart(callbacks); const route = createRoutePart(callbacks); - const { fields: routeFields } = route; const { contourInterval, gradeClass, @@ -93,16 +91,6 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh endElevationOffset, enforcePipeClearance, } = settings.fields; - const { - algorithm, - paved, - avoidPass, - minCurveRadius, - maxUphillGrade, - maxDownhillGrade, - minUphillGrade, - minDownhillGrade, - } = routeFields; // 「구조물 배치」 — 두 보기가 같은 폼 한 벌을 쓴다(계획서 24-3b). 종단 보기가 보일 때 폼 알림을 // 받는 동작만 여기서 건다. 계곡 통과 시설(A군)은 관 지점 정본(배수유역도)과 연동한다. @@ -120,15 +108,7 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh }); const inputElements = [ - algorithm, gradeClass, - paved, - avoidPass, - minCurveRadius, - maxUphillGrade, - maxDownhillGrade, - minUphillGrade, - minDownhillGrade, crossSampleInterval, longSampleInterval, designSpeed, @@ -160,15 +140,7 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh values(): RoutePanelValues { return { contourInterval: Number(contourInterval.value) || 1, - algorithm: algorithm.value as RoutePanelValues["algorithm"], gradeClass: gradeClass.value as RoutePanelValues["gradeClass"], - paved: paved.checked, - minCurveRadius: parseOptional(minCurveRadius), - maxUphillGrade: parseOptional(maxUphillGrade), - maxDownhillGrade: parseOptional(maxDownhillGrade), - minUphillGrade: parseOptional(minUphillGrade), - minDownhillGrade: parseOptional(minDownhillGrade), - allowAvoidPassThrough: avoidPass.checked, stationInterval: STATION_INTERVAL_M, crossSampleInterval: parseOptional(crossSampleInterval), longSampleInterval: parseOptional(longSampleInterval), @@ -184,15 +156,7 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh }, restore(values: Partial) { if (values.contourInterval != null) contourInterval.value = String(values.contourInterval); - if (values.algorithm) algorithm.value = values.algorithm; if (values.gradeClass) gradeClass.value = values.gradeClass; - if (values.paved != null) paved.checked = values.paved; - if (values.minCurveRadius != null) minCurveRadius.value = String(values.minCurveRadius); - if (values.maxUphillGrade != null) maxUphillGrade.value = String(values.maxUphillGrade); - if (values.maxDownhillGrade != null) maxDownhillGrade.value = String(values.maxDownhillGrade); - if (values.minUphillGrade != null) minUphillGrade.value = String(values.minUphillGrade); - if (values.minDownhillGrade != null) minDownhillGrade.value = String(values.minDownhillGrade); - if (values.allowAvoidPassThrough != null) avoidPass.checked = values.allowAvoidPassThrough; // stationInterval은 복원하지 않는다 — 20m 고정(2026-08-18). if (values.crossSampleInterval != null) crossSampleInterval.value = String(values.crossSampleInterval); diff --git a/B05_Profile/B05_Profile_UI_Panel_Fields.ts b/B05_Profile/B05_Profile_UI_Panel_Fields.ts index 85c615371..9b1fc2097 100644 --- a/B05_Profile/B05_Profile_UI_Panel_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Panel_Fields.ts @@ -9,7 +9,6 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; export interface RoutePanelValues { contourInterval: number; - algorithm: "dijkstra" | "ridge_valley"; /** 임도 종류 — 현행 3종(간선·산불진화·작업). branch(지선)는 폐지됐으나 기존 * 저장분 호환을 위해 값으로는 받는다(2026-08-19). 값의 원천은 프로젝트 등록 * 정보(projects.road_type)이며 화면에서는 고르지 않는다. */ @@ -17,13 +16,6 @@ export interface RoutePanelValues { /** 설계속도(km/h) — 임도는 속도를 낼 수 없는 노선이라 기본 20이다. 간선·산불진화만 * 20~40에서 고를 수 있고 작업임도는 20 고정(별표2, 2026-08-19 사용자 확정). */ designSpeed: 20 | 30 | 40; - paved: boolean; - minCurveRadius: number | null; - maxUphillGrade: number | null; - maxDownhillGrade: number | null; - minUphillGrade: number | null; - minDownhillGrade: number | null; - allowAvoidPassThrough: boolean; stationInterval: number | null; crossSampleInterval: number | null; longSampleInterval: number | null; diff --git a/B05_Profile/B05_Profile_UI_Panel_Route.ts b/B05_Profile/B05_Profile_UI_Panel_Route.ts index 76ce7897d..4adc6d72b 100644 --- a/B05_Profile/B05_Profile_UI_Panel_Route.ts +++ b/B05_Profile/B05_Profile_UI_Panel_Route.ts @@ -1,16 +1,14 @@ /* ============================================================================= * B05_Profile_UI_Panel_Route.ts - * 좌측 패널의 **노선 쪽 칸 재료** — 계획노선 편집 단추 · 경로 계산 설정(포인트 팔레트 · 알고리즘 · - * 조건) · 선택 포인트 상세. 칸 틀은 `B05_Profile_UI_Panel_Sections.ts` 가 씌운다(등록부). + * 좌측 패널의 **노선 쪽 칸 재료** — 계획노선 편집 단추 · 선택 포인트 상세. 칸 틀은 + * `B05_Profile_UI_Panel_Sections.ts` 가 씌운다(등록부). 「경로 계산 설정」은 `Old/` 로 옮김(54-24). * `B05_Profile_UI_Panel.ts` 에서 떼어냈다(계획서 24-2b). 본문 로직과 수치는 그대로다. * ========================================================================== */ -import { createSelectField } from "@ui/ui_template_elements"; -import type { PlacedRoutePoint, RoutePointKind } from "./B05_Profile_UI_Markers"; -import { button, checkbox, numberField } from "./B05_Profile_UI_Panel_Fields"; +import type { PlacedRoutePoint } from "./B05_Profile_UI_Markers"; +import { button, numberField } from "./B05_Profile_UI_Panel_Fields"; export interface RoutePartCallbacks { - onSolve: () => void; /** [계획노선 편집] — 큰 모달을 열어 노선을 고친다(계산은 모달 [확인]에서만 돈다). */ onEditPlannedRoute: () => void; onMovePoint: () => void; @@ -28,57 +26,6 @@ export function createRoutePart(callbacks: RoutePartCallbacks) { plannedRouteBtn.title = "예상노선 위에서 계획노선을 고칩니다. [확인] 때만 다시 계산합니다."; plannedRouteBtn.addEventListener("click", () => callbacks.onEditPlannedRoute()); - const paletteGrid = document.createElement("div"); - paletteGrid.className = "b05-route__palette"; - const pointLabels: Record = { - bp: "BP 시작점", - ep: "EP 종료점", - cp: "CP 경유점", - ap: "AP 회피구역", - fp: "FP 금지구역", - }; - (Object.keys(pointLabels) as RoutePointKind[]).forEach((kind) => { - const chip = document.createElement("div"); - chip.className = `b05-route__chip is-${kind}`; - chip.draggable = true; - chip.textContent = pointLabels[kind]; - chip.addEventListener("dragstart", (event) => event.dataTransfer?.setData("pointType", kind)); - paletteGrid.append(chip); - }); - - // 드롭다운은 공통 컴포넌트(createSelectField) 재사용. `.select`로 받아 이하 로직 불변. - const algorithmField = createSelectField({ - label: "알고리즘", - options: [ - { value: "dijkstra", text: "Dijkstra" }, - { value: "ridge_valley", text: "능선·계곡" }, - ], - }); - const algorithm = algorithmField.select; - const minCurveRadius = numberField("최소 곡선반경 (m)"); - const maxUphillGrade = numberField("오르막 경사 상한 (%)"); - const maxDownhillGrade = numberField("내리막 경사 상한 (%)"); - const minUphillGrade = numberField("오르막 경사 하한 (%)"); - const minDownhillGrade = numberField("내리막 경사 하한 (%)"); - const paved = checkbox("포장 임도", false); - const avoidPass = checkbox("회피구역 통과 허용", false); - const details = document.createElement("details"); - const summary = document.createElement("summary"); - summary.textContent = "사용한 조건"; - details.append( - summary, - minCurveRadius.wrapper, - maxUphillGrade.wrapper, - maxDownhillGrade.wrapper, - minUphillGrade.wrapper, - minDownhillGrade.wrapper, - ); - // [최적 경로 계산] 입구는 2026-09-06 사용자 확정으로 화면에서 내렸다 — 평면만 보고 - // 노선을 정하는 방식이라 실제 판단(평면·종단·횡단·유토곡선을 함께 봄)과 맞지 않는다. - // 코드·API 는 그대로 두고 버튼만 뺀다. 노선 변경은 계획노선 편집이 대신한다. - const solveButton = button("최적 경로 계산", callbacks.onSolve, "filled"); - solveButton.hidden = true; - const selectedName = document.createElement("strong"); const radius = numberField("회피/금지 반경 (m)", "25"); radius.addEventListener("change", () => callbacks.onRadiusChange(Number(radius.value) || 1)); @@ -93,27 +40,8 @@ export function createRoutePart(callbacks: RoutePartCallbacks) { return { plannedRouteButton: plannedRouteBtn, - /** 「경로 계산 설정」 본문 — 화면 순서대로. */ - calcNodes: [ - paletteGrid, - algorithmField.root, - paved.wrapper, - avoidPass.wrapper, - details, - solveButton, - ] as HTMLElement[], /** 「선택 포인트 상세 설정」 본문 — 화면 순서대로. */ selectedNodes: [selectedName, radius.wrapper, selectedActions] as HTMLElement[], - fields: { - algorithm, - paved, - avoidPass, - minCurveRadius, - maxUphillGrade, - maxDownhillGrade, - minUphillGrade, - minDownhillGrade, - }, bindSelectedRoot(root: HTMLElement): void { selectedRoot = root; root.hidden = true; diff --git a/B05_Profile/B05_Profile_UI_Panel_Sections.ts b/B05_Profile/B05_Profile_UI_Panel_Sections.ts index aa5437eec..b676383b6 100644 --- a/B05_Profile/B05_Profile_UI_Panel_Sections.ts +++ b/B05_Profile/B05_Profile_UI_Panel_Sections.ts @@ -1,10 +1,10 @@ /* ============================================================================= * B05_Profile_UI_Panel_Sections.ts * 종단 보기 좌측 패널의 **칸 정의**(등록부 `registerPanelSection`) — 페이지 설정 · 계획노선 · - * 경로 계산 설정 · 선택 포인트 상세. 「구조물 배치」는 두 보기 공용이라 `design_sections.ts` 에 있다. + * 선택 포인트 상세. 「구조물 배치」는 두 보기 공용이라 `design_sections.ts` 에 있다. * - * 칸 차례(2026-08-18 사용자 확정): 구조물 배치 > 페이지 설정 > 계획노선 > 경로 계산 설정(비활성) - * > 선택 포인트(평소 숨김) > 아래 고정 영역. 재료는 `_Panel_Settings` · `_Panel_Route` 가 만들어 + * 칸 차례(2026-08-18 사용자 확정): 구조물 배치 > 페이지 설정 > 계획노선 + * > 선택 포인트(평소 숨김) > 아래 고정 영역. 「경로 계산 설정」은 `Old/` 로 옮김(54-24). 재료는 `_Panel_Settings` · `_Panel_Route` 가 만들어 * `ctx.parts` 로 건넨다. * ========================================================================== */ @@ -37,23 +37,6 @@ registerPanelSection({ }, }); -registerPanelSection({ - id: "route-calc", - title: "경로 계산 설정", - views: ["profile"], - order: 40, - build: (ctx, view) => { - const frame = createSectionFrame(view, "경로 계산 설정", true); - frame.body.append(...part(ctx, "route").calcNodes); - // 경로 계산 설정 — 사용여부 미결정으로 조작 불가(숨기지 않는다, 2026-08-18 사용자 - // 지시). inert가 본문 전체 입력·드래그를 막고, is-disabled가 흐림을 그린다. - // 제목 클릭(접기/펼치기)은 본문 밖이라 그대로 산다. - frame.root.classList.add("is-disabled"); - frame.body.inert = true; - return frame.root; - }, -}); - registerPanelSection({ id: "selected-point", title: "선택 포인트 상세 설정", diff --git a/B05_Profile/Old/B05_Profile_UI_RouteCalc.ts b/B05_Profile/Old/B05_Profile_UI_RouteCalc.ts new file mode 100644 index 000000000..adffbd73a --- /dev/null +++ b/B05_Profile/Old/B05_Profile_UI_RouteCalc.ts @@ -0,0 +1,192 @@ +/* Old — 54-24 · 나중에 삭제 */ +/* ============================================================================= + * B05_Profile_UI_RouteCalc.ts + * 좌측 패널 「경로 계산 설정」 칸 — 포인트 팔레트 · 알고리즘 · 조건 · [최적 경로 계산](숨김). + * `B05_Profile_UI_Panel_Route.ts` · `_Panel_Sections.ts` · `_Page_Actions.ts` · `_Page_Helpers.ts` + * 에서 떼어 옮겼다. 어디서도 import 하지 않는다(tsconfig 제외). + * ========================================================================== */ + +import { + createSelectField, + hideLoadingOverlay, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { + createSectionFrame, + part, + registerPanelSection, +} from "../../A00_Common/design/design_panel"; +import { solveRoute, type CirclePoint, type RoutePoint } from "../B05_Profile_Api_Fetch"; +import type { PlacedRoutePoint, RoutePointKind } from "../B05_Profile_UI_Markers"; +import { button, checkbox, numberField, parseOptional } from "../B05_Profile_UI_Panel_Fields"; +import type { PageActionContext } from "../B05_Profile_UI_Page_Actions"; + +export function createRouteCalcPart(callbacks: { onSolve: () => void }) { + const paletteGrid = document.createElement("div"); + paletteGrid.className = "b05-route__palette"; + const pointLabels: Record = { + bp: "BP 시작점", + ep: "EP 종료점", + cp: "CP 경유점", + ap: "AP 회피구역", + fp: "FP 금지구역", + }; + (Object.keys(pointLabels) as RoutePointKind[]).forEach((kind) => { + const chip = document.createElement("div"); + chip.className = `b05-route__chip is-${kind}`; + chip.draggable = true; + chip.textContent = pointLabels[kind]; + chip.addEventListener("dragstart", (event) => event.dataTransfer?.setData("pointType", kind)); + paletteGrid.append(chip); + }); + + // 드롭다운은 공통 컴포넌트(createSelectField) 재사용. `.select`로 받아 이하 로직 불변. + const algorithmField = createSelectField({ + label: "알고리즘", + options: [ + { value: "dijkstra", text: "Dijkstra" }, + { value: "ridge_valley", text: "능선·계곡" }, + ], + }); + const algorithm = algorithmField.select; + const minCurveRadius = numberField("최소 곡선반경 (m)"); + const maxUphillGrade = numberField("오르막 경사 상한 (%)"); + const maxDownhillGrade = numberField("내리막 경사 상한 (%)"); + const minUphillGrade = numberField("오르막 경사 하한 (%)"); + const minDownhillGrade = numberField("내리막 경사 하한 (%)"); + const paved = checkbox("포장 임도", false); + const avoidPass = checkbox("회피구역 통과 허용", false); + const details = document.createElement("details"); + const summary = document.createElement("summary"); + summary.textContent = "사용한 조건"; + details.append( + summary, + minCurveRadius.wrapper, + maxUphillGrade.wrapper, + maxDownhillGrade.wrapper, + minUphillGrade.wrapper, + minDownhillGrade.wrapper, + ); + // [최적 경로 계산] 입구는 2026-09-06 사용자 확정으로 화면에서 내렸다 — 평면만 보고 + // 노선을 정하는 방식이라 실제 판단(평면·종단·횡단·유토곡선을 함께 봄)과 맞지 않는다. + const solveButton = button("최적 경로 계산", callbacks.onSolve, "filled"); + solveButton.hidden = true; + + return { + /** 「경로 계산 설정」 본문 — 화면 순서대로. */ + calcNodes: [ + paletteGrid, + algorithmField.root, + paved.wrapper, + avoidPass.wrapper, + details, + solveButton, + ] as HTMLElement[], + fields: { + algorithm, + paved, + avoidPass, + minCurveRadius, + maxUphillGrade, + maxDownhillGrade, + minUphillGrade, + minDownhillGrade, + }, + }; +} + +export type RouteCalcPart = ReturnType; + +registerPanelSection({ + id: "route-calc", + title: "경로 계산 설정", + views: ["profile"], + order: 40, + build: (ctx, view) => { + const frame = createSectionFrame(view, "경로 계산 설정", true); + frame.body.append(...part(ctx, "routeCalc").calcNodes); + // 경로 계산 설정 — 사용여부 미결정으로 조작 불가(숨기지 않는다, 2026-08-18 사용자 + // 지시). inert가 본문 전체 입력·드래그를 막고, is-disabled가 흐림을 그린다. + // 제목 클릭(접기/펼치기)은 본문 밖이라 그대로 산다. + frame.root.classList.add("is-disabled"); + frame.body.inert = true; + return frame.root; + }, +}); + +export function routePoint(point: PlacedRoutePoint): RoutePoint { + // 서버 스키마는 표고를 빼면 "모름"으로 받는다 — null은 undefined로 바꿔 보낸다. + return { x: point.x, y: point.y, z: point.z ?? undefined }; +} + +export function circlePoint(point: PlacedRoutePoint): CirclePoint { + return { ...routePoint(point), radius_m: point.radius_m ?? 25 }; +} + +/** [경로 계산] — 마커·패널 값으로 노선을 풀고 종횡단까지 다시 받는다. */ +export async function solveRouteAction( + ctx: PageActionContext, + calc: RouteCalcPart["fields"], +): 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.model_id ?? undefined, + algorithm: calc.algorithm.value, + 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: calc.paved.checked, + min_curve_radius_m: parseOptional(calc.minCurveRadius), + max_uphill_grade: parseOptional(calc.maxUphillGrade), + max_downhill_grade: parseOptional(calc.maxDownhillGrade), + min_uphill_grade: parseOptional(calc.minUphillGrade), + min_downhill_grade: parseOptional(calc.minDownhillGrade), + allow_avoid_pass_through: calc.avoidPass.checked, + 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(); + } +} diff --git a/resources/tester/helper_design_view_parity.cjs b/resources/tester/helper_design_view_parity.cjs index e64fa9a87..e94b6161e 100644 --- a/resources/tester/helper_design_view_parity.cjs +++ b/resources/tester/helper_design_view_parity.cjs @@ -199,7 +199,6 @@ const built = panel.createDesignPanel( settings: { nodes: [text("a"), text("b")] }, route: { plannedRouteButton: text("plan"), - calcNodes: [text("calc")], selectedNodes: [text("sel")], bindSelectedRoot: (root) => { root.hidden = true; diff --git a/resources/tester/test_design_view_parity.py b/resources/tester/test_design_view_parity.py index c56ec2f2f..98906a2bb 100644 --- a/resources/tester/test_design_view_parity.py +++ b/resources/tester/test_design_view_parity.py @@ -59,7 +59,7 @@ def results(): def test_보기를_오가도_좌측_패널_단추_구조물_배치는_같은_노드(results): assert results["sameNodes"] == {"cross": True, "back": True} # 보기마다 새로 만들지 않는다 — 칸은 처음 보일 때 한 번만 서고 뒤로는 숨김 · 보임만. - assert results["childCount"] == 1 + 5 + 2 # 아래 고정 영역 + 종단 칸 다섯 + 횡단 칸 둘 + assert results["childCount"] == 1 + 4 + 2 # 아래 고정 영역 + 종단 칸 넷 + 횡단 칸 둘 def test_구조물_배치는_두_보기_모두_맨_위_같은_DOM(results): @@ -80,7 +80,6 @@ def test_보기마다_제_칸만_보인다(results): "구조물 배치", "페이지 설정", "계획노선", - "경로 계산 설정", "선택 포인트 상세 설정", ] assert views["cross"]["shownTitles"] == ["구조물 배치", "횡단 보기 설정", "표준 횡단면 설정"] diff --git a/tsconfig.json b/tsconfig.json index ef30af22e..256f2e4d2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,5 +33,5 @@ } }, "include": ["A00_Common", "A0*", "B0*", "B1*", "M0*", "Z0*", "ui_template", "config/**/*", "common_util"], - "exclude": ["0_old", "node_modules", "venv", "dist"] + "exclude": ["0_old", "B05_Profile/Old", "node_modules", "venv", "dist"] } From 717c28a9560f72a9b1ea17f06fdf9eae1375d8b2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 12:38:41 +0900 Subject: [PATCH 2/2] =?UTF-8?q?perf(B06):=2054-22=20S1=20=ED=9A=A1?= =?UTF-8?q?=EB=8B=A8=20=EB=B3=B4=EA=B8=B0=20=E2=96=B2=E2=96=BC=20=EB=A9=88?= =?UTF-8?q?=EC=B6=A4=20=EB=90=98=EB=8F=8C=EB=A6=BC=20=E2=80=94=20120ms=20?= =?UTF-8?q?=EB=94=94=EB=B0=94=EC=9A=B4=EC=8A=A4=20=C2=B7=20=EC=A2=85?= =?UTF-8?q?=EB=8B=A8=EB=8F=84=20=ED=95=9C=20=EB=B2=88=20=C2=B7=20=EB=B0=94?= =?UTF-8?q?=EB=80=90=20=EC=B9=B4=EB=93=9C=EB=A7=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 횡단 재계산 대기: 횡단 보기 120ms · 종단 보기 16ms 그대로(실시간) - 계획고 편집 반영 알림을 받은 횡단 보기는 카드만 갈아 끼움 — 종단도는 보낸 쪽이 이미 그림 - 숨은 횡단 보기는 알림 건너뜀 — 다시 보일 때 정합이 카드를 맞춤 - 재계산이 실제 값이 바뀐 측점만 돌려줌 — ▲ 한 번에 66장 전부 갈아 끼우던 것 → 3장 - 실측(sub7_검증 · 66 측점 · 540m ▲): 한 번 최장 577~585 → 58~81ms · 2.5초 길게 누적 1,468 → 236~282ms - B05 ▲: 50ms 넘는 멈춤 0 · 누른 뒤 55~67ms 반영 · 길게 누름 중 100ms 마다 따라옴 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- A00_Common/design/design_profile.ts | 5 +- B05_Profile/B05_Profile_UI_Profile_Preview.ts | 5 +- B06_Section/B06_Section_Cross_Refresh.ts | 7 +- B06_Section/B06_Section_UI_Section_View.ts | 9 +- .../B06_Section_UI_Section_View_Profile.ts | 5 +- .../tester/ui_checks/54-22_횡단_올림_멈춤.js | 83 +++++++++++++++++++ 6 files changed, 105 insertions(+), 9 deletions(-) create mode 100644 resources/tester/ui_checks/54-22_횡단_올림_멈춤.js diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index def0e97d1..22322e4c1 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -121,7 +121,8 @@ export interface DesignProfileContext { * 계산이 브라우저 안에서 끝나면서(2026-09-03) 서버 왕복이 사라져 250→60ms 로 줄였다 — * 실측 전 측점(128) 재계산 7.5ms 라 손을 떼는 즉시 유토곡선이 따라온다. * 60→16ms(2026-09-06) — ▲ 한 번에 라벨이 서기까지 중앙 130ms 를 쟀는데 그중 60ms 가 - * 이 대기였다. 한 프레임(16ms)이면 연속 입력은 여전히 마지막 값만 계산한다. */ + * 이 대기였다. 한 프레임(16ms)이면 연속 입력은 여전히 마지막 값만 계산한다. + * 횡단 보기는 120ms — 계산마다 카드를 갈아 끼워 길게 누르면 멈춤이 쌓임(9da48d5e7 · 54-22). */ const CROSS_PREVIEW_DEBOUNCE_MS = 16; export function createDesignProfile(ctx: DesignProfileContext) { @@ -384,7 +385,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { detail: () => detail, routeId: () => routeId, edits: () => store.edits(), - debounceMs: CROSS_PREVIEW_DEBOUNCE_MS, + debounceMs: () => (root.dataset.view === "cross" ? 120 : CROSS_PREVIEW_DEBOUNCE_MS), onApplied: (updated) => { draw(); callbacks?.onCrossDesignsUpdated?.(updated); diff --git a/B05_Profile/B05_Profile_UI_Profile_Preview.ts b/B05_Profile/B05_Profile_UI_Profile_Preview.ts index 3e9cbc464..ac59faf63 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Preview.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Preview.ts @@ -18,7 +18,8 @@ export interface CrossPreviewContext { detail: () => SectionDetailResponse | null; routeId: () => number | null; edits: () => AlignmentEdits; - debounceMs: number; + /** 부를 때마다 읽음 — 보기마다 다름. */ + debounceMs: () => number; /** 반영이 끝난 뒤 다시 그린다 — 다시 계산한 측점 누가거리를 넘긴다(횡단 카드 갱신용). */ onApplied: (updated: number[]) => void; } @@ -61,7 +62,7 @@ export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview { .catch(() => { /* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */ }); - }, ctx.debounceMs); + }, ctx.debounceMs()); }, dispose() { window.clearTimeout(timer); diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 390129c2b..16f1a143b 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -124,8 +124,11 @@ function preserveUserFields( /** * 전 측점 횡단을 현재 계획선으로 다시 계산해 `detail.cross_sections[].design`을 제자리 교체한다. * 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다. + * 계획선 ▲ 한 번에 값이 바뀌는 측점은 몇 개뿐 — 전부 돌려주면 카드를 다 갈아 끼워 멈춤(54-22). */ export async function refreshCrossDesigns(input: CrossRefreshInput): Promise { + const sections = input.detail.cross_sections; + const before = new Map(sections.map((section) => [section, JSON.stringify(section.design)])); const updated = refreshLocally(input) ?? (await refreshFromServer(input)); // 구조물이 선 측점은 폐회로 면적이 정본이다 — 설계를 갈아 끼운 **이 자리에서 한 번** 다시 // 얹는다. 부르는 쪽마다 얹으면 B06 흐름만 얹고 B05 는 안 얹어 두 보기의 유토 뱃지가 @@ -136,7 +139,9 @@ export async function refreshCrossDesigns(input: CrossRefreshInput): Promise JSON.stringify(section.design) !== before.get(section)) + .map((section) => section.chainage_m); } /** diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 47097869c..b857bea3e 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -121,7 +121,8 @@ export function createSectionView( else revealCard(stationId, "smooth"); }, edit: () => structureEdit, - refreshCards: (chainages) => refreshCards(chainages), + // 종단도는 보낸 쪽(계획고 편집 반영)이 이미 다시 그림 — 카드만(54-22) + refreshCards: (chainages) => refreshCards(chainages, false), }, shared as DesignPageShared, ); @@ -390,10 +391,12 @@ export function createSectionView( * 측점마다 `refreshCard` 를 부르면 측점 수만큼 종단 그래프·유토곡선을 다시 그려, * 계획선 ▲▼ 한 번에 화면이 몇 백 ms 씩 멈췄다(2026-09-12 B05 대비 느림 원인). */ - const refreshCards = (chainages: ReadonlyArray): void => { + const refreshCards = (chainages: ReadonlyArray, redraw = true): void => { let touched = false; for (const chainageM of chainages) if (rebuildCard(chainageM)) touched = true; - if (touched) redrawProfile(); + if (!touched || !currentDetail) return; + if (redraw) redrawProfile(); + else fillSlopeNotice.update(currentDetail.cross_sections, cachedStationInterval); }; const resizeObserver = new ResizeObserver(() => { diff --git a/B06_Section/B06_Section_UI_Section_View_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts index 00b985789..9ea0b7731 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -180,7 +180,10 @@ export function createSectionProfile( profile: { onSelectStation: (stationId) => host.selectCard(cardOf(stationId)), callbacks: { - onCrossDesignsUpdated: (updated) => host.refreshCards(updated), + // 숨은 횡단 보기는 건너뜀 — 다시 보일 때 정합이 카드를 맞춤(onShow) + onCrossDesignsUpdated: (updated) => { + if (shared.view() === "cross") host.refreshCards(updated); + }, onStructureMove: (from, to, station) => { if (isPipeStation(station)) host.edit()?.movePipe(from, to); }, diff --git a/resources/tester/ui_checks/54-22_횡단_올림_멈춤.js b/resources/tester/ui_checks/54-22_횡단_올림_멈춤.js new file mode 100644 index 000000000..736645f6c --- /dev/null +++ b/resources/tester/ui_checks/54-22_횡단_올림_멈춤.js @@ -0,0 +1,83 @@ +/* 54-22 — 종단 점 ▲ 누름 멈춤(긴 작업) 재기: B06 은 120ms 디바운스 · 종단도 한 번 · B05 는 실시간 그대로 + * 시작 화면: B06(#/b06-section) 또는 B05(#/b05-profile) · 검증 프로젝트 · 로그인 + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5422 = "단계";` · 긴 단계는 `void` 로 띄우고 + * `sessionStorage.getItem("__5422_out_{단계}")` 로 읽음 + * snap → 처음 초안(세션 원문)을 `__5422_orig` 에 남김(이미 있으면 그대로) + * one → 가운데 측점 ▲ 한 번 — 긴 작업 최장 · 합 · 횡단 재계산까지 걸린 때 + * hold → 가운데 측점 ▲ 2.5초 길게 — 긴 작업 최장 · 합 · 재계산 횟수 + * restore → 처음 초안 원문으로 되돌림 → 그 뒤 orca reload + * 판정: B06 one 최장 ≈200ms 안팎 · hold 합 ≈0.5초 안팎(고치기 전 587 / 2,684ms) · + * B05 one 재계산 때 = 16ms 디바운스 근처(실시간) + * 쓰는 것: sessionStorage(계획선 초안) — 정본(서버)은 안 씀 + */ +(async () => { + const MODE = window.__m5422 || "one"; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const DRAFTS = /^aislo:draft:/; + const out = { mode: MODE, page: location.hash }; + const save = () => sessionStorage.setItem(`__5422_out_${MODE}`, JSON.stringify(out)); + + if (MODE === "snap") { + if (!sessionStorage.getItem("__5422_orig")) { + const orig = Object.fromEntries( + Object.keys(sessionStorage) + .filter((k) => DRAFTS.test(k)) + .map((k) => [k, sessionStorage.getItem(k)]), + ); + sessionStorage.setItem("__5422_orig", JSON.stringify(orig)); + } + out.keys = Object.keys(JSON.parse(sessionStorage.getItem("__5422_orig"))); + save(); + return JSON.stringify(out); + } + if (MODE === "restore") { + const orig = JSON.parse(sessionStorage.getItem("__5422_orig") || "null"); + if (!orig) return "no orig"; + for (const k of Object.keys(sessionStorage)) if (DRAFTS.test(k)) sessionStorage.removeItem(k); + for (const [k, v] of Object.entries(orig)) sessionStorage.setItem(k, v); + for (const k of Object.keys(sessionStorage)) + if (k.startsWith("__5422")) sessionStorage.removeItem(k); + return JSON.stringify({ restored: Object.keys(orig) }); + } + + const ups = [...document.querySelectorAll(".b05-profile-edit__btn.is-up")]; + const button = ups[Math.floor(ups.length / 2)]; + if (!button) { + out.error = "▲ 없음"; + save(); + return JSON.stringify(out); + } + out.button = button.title.split("\n")[0]; + const tasks = []; + const observer = new PerformanceObserver((list) => + list.getEntries().forEach((e) => tasks.push(Math.round(e.duration))), + ); + observer.observe({ type: "longtask" }); + // 횡단 재계산이 카드 · 캐시에 닿은 때 — 계획선 초안 말고 다른 캐시 쓰기 + const writes = []; + const t0 = performance.now(); + const origSet = Storage.prototype.setItem; + Storage.prototype.setItem = function (k, v) { + if (this === sessionStorage && !k.startsWith("__5422")) + writes.push([Math.round(performance.now() - t0), k.replace(/:[0-9a-f-]{36}.*/, "")]); + return origSet.call(this, k, v); + }; + // 화면이 바뀐 때(ms) — 누른 뒤 재계산 반영이 닿는 때를 봄(B05 ≈16ms 디바운스 · B06 ≈120ms) + const domAt = new Set(); + const mo = new MutationObserver(() => domAt.add(Math.round(performance.now() - t0))); + mo.observe(document.body, { subtree: true, childList: true, characterData: true }); + button.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId: 1 })); + await w(MODE === "hold" ? 2500 : 60); + window.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1 })); + await w(2500); + Storage.prototype.setItem = origSet; + observer.disconnect(); + mo.disconnect(); + out.domAt = [...domAt].slice(0, 12); + out.longTasks = tasks; + out.max = tasks.length ? Math.max(...tasks) : 0; + out.sum = tasks.reduce((a, b) => a + b, 0); + out.writes = writes; + save(); + return JSON.stringify(out); +})();