From cdd40a00f359b4d96d4d9cb8432cc97b991e4258 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 4 Aug 2026 18:48:18 +0900 Subject: [PATCH] =?UTF-8?q?feat(B04/B05):=20=EC=9E=AC=ED=99=95=EC=A0=95=20?= =?UTF-8?q?=EC=9E=AC=EA=B3=84=EC=82=B0=20=EC=B2=B4=EC=9D=B8(12)=20+=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EB=B2=84=ED=8A=BC=C2=B7=EC=9C=A0=ED=86=A0?= =?UTF-8?q?=EA=B3=A1=EC=84=A0=20UI=20=EC=A0=95=EB=A6=AC(13~15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 12: run_redesign_chain — B04 재확정 시 사용자 입력(stage 2 params) 유지한 채 새 지표면 기준 B05 재계산·확정, 옛 측점별 설계·표준단면 설정을 chainage 매칭 이월 후 B06 확정. 경로 없으면 신규 자동 체인 폴백. B04 confirm에 백그라운드 결선 - 13: 구간 이동 글리프 ⇧⇩ → 속 찬 ⬆︎⬇︎(21×17·굵게), 규칙·비정규 측점 버튼 최소 간격 20px 캐스케이드 배치로 근접 측점 ▲▼ 겹침 해소 - 14: 유토곡선 손잡이를 표준 삼각형 손잡이(64×24, 바닥 중앙)로 통일, 캡션 제거, '유토곡선 펼치기/접기' 툴팁 - 15: 유토곡선 스크롤러 세로 휠 → 가로 이동(종단 그래프와 동기) Playwright 검증: 버튼 27개 최소 간격 20px, 글리프 ⬆︎ 21×17, 표준 손잡이 64×24, 휠 스크롤 0→400 종단 동기, 콘솔 오류 0. typecheck·ruff 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_Service_Chain.py | 177 ++++++++++++++++++ B04_wf1_Surface/B04_wf1_Surface_Router.py | 12 ++ .../B05_wf2_Route_UI_Profile_Edit.ts | 50 +++-- .../B05_wf2_Route_UI_Profile_MassHaul.ts | 30 ++- B05_wf2_Route/B05_wf2_Route_UI_Style.css | 9 + .../B05_wf2_Route_UI_Style_MassHaul.css | 35 +--- 6 files changed, 269 insertions(+), 44 deletions(-) diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index 8d211d02..ae1dd484 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -146,3 +146,180 @@ async def run_auto_design_chain(project_id: UUID, surface_model_id: int | None = except Exception: # 체인은 업로드·WF1 흐름의 부가 작업이다 — 어떤 예외도 밖으로 던지지 않는다. logger.exception("자동 설계 체인 실패: project_id=%s", project_id) + + +async def run_redesign_chain( + project_id: UUID, + surface_model_id: int, + selection: dict[str, Any], +) -> None: + """B04 재확정 후 — **사용자 입력을 유지한 채** 새 지표면 기준으로 B05·B06 재계산·저장. + + 관리자가 B04에서 다른 지표면 모델로 재확정하면(2026-08-04 사용자 확정) 그 값을 + 기준으로 다음 페이지들도 함께 갱신돼야 한다. 이때 일반 사용자가 이미 쓰던 설정은 + 버리지 않는다: + - B05: 저장된 stage 2 params(제어점 BP/EP/CP·회피/금지원·경사 옵션·측점 간격 등)를 + 그대로 쓰고 **지표면(filter/method/smooth/model id)만** 새 확정값으로 바꾼다. + - B06: 옛 경로의 측점별 설계(지반유형·단면유형·측구·암 경계)를 chainage 매칭으로 + 새 경로에 이월하고, 표준단면 설정(data.options)도 함께 넘긴다. 나머지 미지정 + 측점은 확정 시 기본값으로 채워진다. + + 경로가 아예 없으면 신규 자동 체인(계획노선 CSV 기본값)으로 되돌아간다. + """ + from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path + from B05_wf2_Route.B05_wf2_Route_Repository import get_latest_route + from B05_wf2_Route.B05_wf2_Route_Router import confirm_latest_route, solve_route + from B05_wf2_Route.B05_wf2_Route_Schema import RouteSolveRequest + from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import ( + get_cross_section_designs, + get_longitudinal_section, + update_cross_section_design, + ) + from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Router_Confirm import confirm_sections + from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import SectionConfirmRequest + from common_util.common_util_workflow_state import get_workflow_state + from config.config_db import get_db_pool + + _ = get_project_storage_relative_path # 시그니처 정렬용 (사용 안 함) + pool = get_db_pool() + try: + async with pool.acquire() as connection: + latest = await get_latest_route(connection, project_id) + if not latest: + logger.info( + "재확정 체인 → 기존 경로 없음, 신규 자동 체인으로: project_id=%s", project_id + ) + await run_auto_design_chain(project_id, surface_model_id=surface_model_id) + return + + # 1) 사용자 입력 회수 — 마지막 경로 계산의 stage 2 params가 정본이다. + old_route_id = int(latest["id"]) + async with pool.acquire() as connection: + import aiomysql + + async with connection.cursor(aiomysql.DictCursor) as cursor: + state = await get_workflow_state(cursor, str(project_id)) + old_longitudinal = await get_longitudinal_section(connection, project_id, old_route_id) + old_designs = await get_cross_section_designs(connection, old_route_id) + stage2 = next( + (s for s in (state or {}).get("stages", []) if int(s.get("stage_no", -1)) == 2), None + ) + params = (stage2 or {}).get("params") or {} + points = params.get("points") or {} + options = params.get("options") or {} + if not points.get("bp") or not points.get("ep"): + logger.warning( + "재확정 체인 중단(stage 2 params에 제어점 없음): project_id=%s", project_id + ) + return + + # 2) B05 재계산 — 지표면 관련 값만 새 확정 선택으로 교체, 나머지는 사용자 저장분. + request = RouteSolveRequest( + filter_key=str(selection.get("source_filter") or params.get("filter_key")), + method=str(selection.get("method") or params.get("method") or "dtm"), + smooth=bool(selection.get("smooth", params.get("smooth", False))), + surface_model_id=surface_model_id, + algorithm=str(params.get("algorithm") or "dijkstra"), + bp=points["bp"], + ep=points["ep"], + cp=points.get("cp") or [], + ap=points.get("ap") or [], + fp=points.get("fp") or [], + station_interval_m=params.get("station_interval_m"), + cross_half_width_m=params.get("cross_half_width_m"), + cross_sample_interval_m=params.get("cross_sample_interval_m"), + long_sample_interval_m=params.get("long_sample_interval_m"), + **{key: options.get(key) for key in ("grade_class",) if options.get(key)}, + paved=bool(options.get("paved", False)), + terrain_type=str(options.get("terrain_type") or "normal"), + main_direction=str(options.get("main_direction") or "auto"), + min_curve_radius_m=options.get("min_curve_radius_m"), + max_uphill_grade=options.get("max_uphill_grade"), + max_downhill_grade=options.get("max_downhill_grade"), + min_uphill_grade=options.get("min_uphill_grade"), + min_downhill_grade=options.get("min_downhill_grade"), + weights=options.get("weights"), + allow_avoid_pass_through=bool(options.get("allow_avoid_pass_through", False)), + max_grade_pct=params.get("max_grade_pct"), + min_vertical_radius_m=params.get("min_vertical_radius_m"), + min_tangent_length_m=params.get("min_tangent_length_m"), + balance_segment_length_m=params.get("balance_segment_length_m"), + start_elevation_offset_m=params.get("start_elevation_offset_m"), + end_elevation_offset_m=params.get("end_elevation_offset_m"), + ) + solve_result: Any = await solve_route(project_id, request) + if isinstance(solve_result, JSONResponse): + logger.error( + "재확정 체인 중단(B05 재계산 실패): project_id=%s status=%s", + project_id, + solve_result.status_code, + ) + return + new_route_id = int(solve_result.route_id) + logger.info( + "재확정 체인 B05 재계산 완료: project_id=%s %s→%s", + project_id, + old_route_id, + new_route_id, + ) + + # 3) 옛 측점별 사용자 설계를 chainage 매칭으로 새 경로에 이월(비치명적). + carried = 0 + try: + async with pool.acquire() as connection: + await connection.begin() + try: + for record in old_designs: + design = record.get("design") + if not isinstance(design, dict): + continue + await update_cross_section_design( + connection, + route_id=new_route_id, + chainage_m=float(record["chainage_m"]), + design=design, + project_id=project_id, + ) + carried += 1 + await connection.commit() + except Exception: + await connection.rollback() + raise + except Exception: + logger.exception( + "재확정 체인 — 옛 설계 이월 실패(계속 진행): project_id=%s", project_id + ) + logger.info("재확정 체인 설계 이월: project_id=%s %d건", project_id, carried) + + # 4) B05 확정 → B06 확정(옛 표준단면 설정 이월, 미지정 측점 기본값 채움). + confirm_result = await confirm_latest_route(project_id, None) + if isinstance(confirm_result, JSONResponse): + logger.error( + "재확정 체인 중단(B05 확정 실패): project_id=%s status=%s", + project_id, + confirm_result.status_code, + ) + return + old_options = ((old_longitudinal or {}).get("data") or {}).get("options") or {} + section_request = ( + SectionConfirmRequest(standard_cross_section=old_options["standard_cross_section"]) + if old_options.get("standard_cross_section") + else None + ) + sections_result = await confirm_sections(project_id, new_route_id, section_request) + if isinstance(sections_result, JSONResponse): + logger.error( + "재확정 체인 중단(B06 확정 실패): project_id=%s status=%s", + project_id, + sections_result.status_code, + ) + return + logger.info( + "재확정 체인 완료: project_id=%s route %s→%s (설계 %d건 이월)", + project_id, + old_route_id, + new_route_id, + carried, + ) + except Exception: + logger.exception("재확정 체인 실패: project_id=%s", project_id) diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router.py b/B04_wf1_Surface/B04_wf1_Surface_Router.py index c9cfe5a2..15982ca8 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router.py @@ -262,6 +262,18 @@ async def confirm_surface( except Exception: await connection.rollback() raise + # 재확정 체인(2026-08-04 사용자 확정) — 새 지표면 기준으로 B05·B06을 백그라운드 + # 재계산·저장한다. 사용자 저장 입력(제어점·경사 옵션·측점별 설계)은 유지·이월된다. + # 응답을 막지 않도록 백그라운드로 돌리고, 체인은 실패를 스스로 격리한다. + from B03_FileInput.B03_FileInput_Service_Chain import run_redesign_chain + + redesign_task = asyncio.create_task( + run_redesign_chain(project_id, request.model_id, dict(selection)), + name=f"redesign-chain-{project_id}", + ) + redesign_task.add_done_callback( + lambda task: task.exception() # 체인 내부에서 이미 로깅 — 미회수 예외 경고만 방지 + ) return SurfaceConfirmResponse(project_id=str(project_id), model_id=request.model_id) except LookupError as exc: return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Edit.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Edit.ts index 71fc1552..caad1796 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Edit.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Edit.ts @@ -235,10 +235,35 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement { const repeater = createHoldRepeater(); const edited = new Set(Object.keys(alignment.edits.station_offsets)); - const stationXs = [ - ...alignment.stations.map((station) => x(station.chainage_m)), - ...irregular.map((station) => x(station.chainage_m)), - ]; + + /** + * 규칙·비정규 측점을 chainage 순으로 합치고, 버튼끼리 겹치지 않게 자리를 잡는다. + * 비정규 측점(구조물)이 규칙 측점 바로 옆에 서면 ▲▼가 서로 덮였다(2026-08-04 사용자 + * 보고) — 앞 버튼과 최소 간격(BUTTON_CLEARANCE_PX)을 강제하며 오른쪽으로 밀어낸다. + * 측점선 자체는 그대로라 버튼만 살짝 비켜선다. + */ + const stationPlacements = [ + ...alignment.stations.map((station) => ({ + chainage: station.chainage_m, + plan: station.plan_elevation_m, + })), + ...irregular.map((station) => ({ + chainage: station.chainage_m, + plan: planElevationAtSample(alignment, station.chainage_m), + })), + ] + .sort((a, b) => a.chainage - b.chainage) + .map( + (() => { + let previousX = Number.NEGATIVE_INFINITY; + return (entry: { chainage: number; plan: number }) => { + const placed = Math.max(x(entry.chainage), previousX + BUTTON_CLEARANCE_PX); + previousX = placed; + return { ...entry, left: placed }; + }; + })(), + ); + const stationXs = stationPlacements.map((entry) => entry.left); /** * 구간 버튼을 측점 버튼과 같은 행에 두되, 겹치는 자리면 옆으로 비킨다. @@ -259,8 +284,8 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement { } // 측점 하나에 ▲/▼(+편집됐으면 원복 ↺) 버튼을 단다. 규칙·비정규 측점 공용 — 비정규 측점도 // `onStation`이 임의 chainage를 변화점으로 승격시키므로 규칙 측점과 완전히 같은 파이프라인이다. - function addStationButtons(chainageM: number, planElevationM: number): void { - const left = x(chainageM); + // left는 겹침 회피가 끝난 화면 x — 측점 수직선(x(chainage))과 다를 수 있다. + function addStationButtons(chainageM: number, planElevationM: number, left: number): void { const isEdited = edited.has(chainageKey(chainageM)); const label = `${chainageM.toFixed(1)}m 계획고 ${planElevationM.toFixed(2)}m`; @@ -291,12 +316,7 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement { layer.append(reset); } - alignment.stations.forEach((station) => - addStationButtons(station.chainage_m, station.plan_elevation_m), - ); - irregular.forEach((station) => - addStationButtons(station.chainage_m, planElevationAtSample(alignment, station.chainage_m)), - ); + stationPlacements.forEach((entry) => addStationButtons(entry.chainage, entry.plan, entry.left)); alignment.segments.forEach((segment) => { const left = x(segment.from_m); @@ -306,14 +326,16 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement { const label = `구간 ${segment.from_m.toFixed(0)}~${segment.to_m.toFixed(0)}m ` + `(구배 ${segment.grade_percent.toFixed(2)}%) 전체 평행이동`; - const up = overlayButton(repeater, "is-segment is-up", "⇧", `${label} — ${step}m 올림`, () => + // 구간 이동 화살표는 속이 찬 글리프(⬆⬇)를 쓴다(2026-08-04 사용자 지시 — ⇧⇩는 윤곽선뿐이라 + // 흐릿했다). ︎(텍스트 표기 선택자)로 이모지 컬러 렌더링을 막아 방향색이 살게 한다. + const up = overlayButton(repeater, "is-segment is-up", "⬆︎", `${label} — ${step}m 올림`, () => onSegment(segment, step), ); up.style.left = `${center - BUTTON_HALF_PX}px`; const down = overlayButton( repeater, "is-segment is-down", - "⇩", + "⬇︎", `${label} — ${step}m 내림`, () => onSegment(segment, -step), ); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts index bccdfcb5..bf0c81c3 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts @@ -151,13 +151,14 @@ function readVisible(): Set { * @param onChanged 펼침·범례 토글·높이 조절로 다시 그려야 할 때 호출된다(패널 전체 redraw). */ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPanel { + // 손잡이는 다른 패널과 **같은 양식**의 표준 삼각형 손잡이 하나만 쓴다(2026-08-04 사용자 + // 지시 — 예전 풀폭 바 + "유토곡선" 캡션은 다른 패널들과 모양이 달랐다). 무엇의 손잡이인지는 + // 툴팁으로 밝힌다. const handleControl = createWorkflowPanelHandle("bottom", "down"); const handle = document.createElement("div"); handle.className = "b05-profile__masshaul-handle"; - const caption = document.createElement("span"); - caption.className = "b05-profile__masshaul-caption"; - caption.textContent = "유토곡선"; - handle.append(handleControl.root, caption); + handle.append(handleControl.root); + handleControl.root.setAttribute("aria-label", "유토곡선 패널"); // 오버레이 뼈대 — 위 경계 리사이저 + 요약 막대 + 가로 스크롤러 + 범례. const overlay = document.createElement("div"); @@ -190,6 +191,23 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa }); overlay.append(resizer.root, bar, scroll, legendLayer); + // 종단 그래프 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(2026-08-04 사용자 지시). + // scrollLeft 동기화 덕에 위 종단 그래프도 함께 움직인다. Shift+휠은 브라우저 기본 그대로. + scroll.addEventListener( + "wheel", + (event) => { + if (event.shiftKey || event.deltaY === 0) return; + const limit = scroll.scrollWidth - scroll.clientWidth; + if (limit <= 0) return; + const delta = event.deltaY; + if ((delta < 0 && scroll.scrollLeft <= 0) || (delta > 0 && scroll.scrollLeft >= limit)) + return; + scroll.scrollLeft += delta; + event.preventDefault(); + }, + { passive: false }, + ); + let open = sessionStorage.getItem(OPEN_KEY) === "true"; let context: RouteMassHaulContext | null = null; @@ -197,15 +215,17 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa open = next; sessionStorage.setItem(OPEN_KEY, String(next)); handleControl.setOpen(next); + // 툴팁은 setOpen이 일반 문구로 덮으므로 매번 유토곡선용으로 다시 밝힌다. + handleControl.root.title = next ? "유토곡선 접기" : "유토곡선 펼치기"; handle.classList.toggle("is-open", next); overlay.hidden = !next; onChanged(); } handleControl.setOpen(open); + handleControl.root.title = open ? "유토곡선 접기" : "유토곡선 펼치기"; handle.classList.toggle("is-open", open); overlay.hidden = !open; handleControl.root.addEventListener("click", () => applyOpen(!open)); - caption.addEventListener("click", () => applyOpen(!open)); function toggleSeries(key: string): void { // 곡선 기준(횡단/종단)은 라디오 — 하나를 고르면 그 기준 그래프만 전체 영역에 보인다. diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index f05d824d..fd250699 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -883,6 +883,15 @@ color: var(--color-danger); } +/* 구간 이동(⬆⬇)은 측점 버튼보다 살짝 크게 — 속 찬 화살표가 잘 읽히도록(2026-08-04). */ +.b05-profile-edit__btn.is-segment { + width: 21px; + height: 17px; + font-size: 12px; + font-weight: 700; + line-height: 1; +} + .b05-profile-edit__btn.is-down { border-color: color-mix(in srgb, var(--color-chart-0, #5b8def) 65%, transparent); color: var(--color-chart-0, #5b8def); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style_MassHaul.css b/B05_wf2_Route/B05_wf2_Route_UI_Style_MassHaul.css index be8a7c44..0d2c1d26 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style_MassHaul.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style_MassHaul.css @@ -18,7 +18,7 @@ .b05-profile__masshaul-overlay { position: absolute; right: 0; - bottom: 20px; /* 유토곡선 접기 손잡이(.b05-profile__masshaul-handle height) 몫 */ + bottom: 0; /* 손잡이는 표준 삼각형(중앙, z-index 6)이라 자리를 차지하지 않는다 */ left: 0; z-index: 5; display: flex; @@ -80,33 +80,18 @@ font-size: var(--text-caption); } -/* 2차 슬라이드 손잡이 — 바깥 패널 손잡이와 같은 문법이되 본문 폭 안에 눕혀 놓는다. */ +/* 2차 슬라이드 손잡이 — 다른 패널들과 **같은 표준 삼각형 손잡이**를 그대로 쓴다 + (2026-08-04 사용자 지시). 패널 본문 바닥 중앙에 붙고, 오버레이(z-index 5) 위에 뜬다. */ .b05-profile__masshaul-handle { - position: relative; - display: flex; - flex: none; - align-items: center; - justify-content: center; - gap: var(--spacing-8); - height: 20px; - border-top: 1px solid var(--color-border); - background: var(--color-surface-raised); + position: absolute; + bottom: 0; + left: 50%; + z-index: 6; + transform: translateX(-50%); } -.b05-profile__masshaul-caption { - color: var(--color-text-secondary); - font-size: var(--text-caption); - line-height: 1; - cursor: pointer; - user-select: none; -} - -.b05-profile__masshaul-handle.is-open .b05-profile__masshaul-caption { - color: var(--color-text-body); - font-weight: 600; -} - -/* 공용 손잡이 버튼은 부모 바닥 경계에 절대배치되도록 만들어졌다 — 이 줄에서는 흐름에 둔다. */ +/* 공용 손잡이는 부모 경계 밖(top: -24px) 절대배치로 만들어졌다 — 여기서는 래퍼가 + 자리를 잡으므로 흐름에 되돌린다. 크기·모양(64×24 삼각형)은 공용 규칙 그대로다. */ .b05-profile__masshaul-handle .ui-workflow-overlay__handle { position: static; transform: none;