From d93bd6e28d8f0bb862268aa43585732de3ffeba1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 6 Aug 2026 19:48:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=ED=9A=A1=EB=8B=A8=20=EB=B0=98?= =?UTF-8?q?=ED=8F=AD=20=EA=B0=9C=ED=8E=B8=C2=B7=EA=B0=9C=EB=B3=84=20?= =?UTF-8?q?=EB=B0=98=ED=8F=AD=C2=B7=EC=9E=AC=EA=B3=84=EC=82=B0=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20+=20=EC=B8=A1=EA=B5=AC=20=EB=B0=A9=ED=96=A5=20?= =?UTF-8?q?=ED=99=95=EC=A0=95=20=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-08-06 사용자 지시 일괄 구현: 반폭 체계: - 초기 샘플 반폭 config 기본 20m(SECTION_CROSS_HALF_WIDTH_M 15→20) — 표시 반폭이 이 안이면 재계산 없이 표시만 자름(기존 crossPlotMetrics) - 재계산 버튼 삭제, [전체 측점 반영]이 반폭 적용 담당: 축소=표시만(즉시), 확대(보유 샘플 폭 초과)=regenerate 후 상세 재로드 - regenerate에 grade_options 재구성 추가 — 계획선(profile_alignment)까지 함께 재계산·저장. 예전 재계산 버튼이 계획 횡단도선·유토곡선·테이블을 지우던 근본 원인 해결(B04 파일입력 파이프라인과 같은 엔진 경로 재활용) - 높이 배율 옵션 폐지(항상 1), 반폭 입력은 표준 횡단면 설정의 [전체 측점 반영] 위로 이동(Standard_Panel extraControl) 개별 반폭(카드): - 카드 하단 ◀/▶/↺(±1m·전역 복귀), 암 경계 그룹 우측 정렬. 숫자 표시 없음 - 개별값 > 전역값 우선(StationWidthControl). 세션 보관, 확정·임시저장 시 cross_patches(design.display_half_width_m)로 영구 저장 → 재접근 복원 - 프리뷰·단건 설계 재계산이 이 필드를 이월해 지우지 않게 보강 - 방위각 표기 삭제 측구 방향 확정 동기화(13측점 보고): - B05 경로확정 uphill 병합 시 저장 횡단 설계도 새 방향으로 재계산·저장 (sync_uphill_overrides_into_designs) — 정본만 갱신하면 B06 표시와 역반영(ditch_side→uphill_side)이 옛 방향으로 순환 덮어쓰던 문제 해결 - 절/성토 역할은 엔진이 지형에서 자동 판정(정상) — 사용자 지정 대상인 측구 방향(ditch_side)이 정확히 동기화됨을 13측점 실측 확인 실측: 축소/확대/개별 조절/저장 복원/확정 동기화/B05 하단 패널 정상, tsc·ruff·prettier 통과, 콘솔 에러 0. Co-Authored-By: Claude Fable 5 --- B05_wf2_Route/B05_wf2_Route_Router.py | 17 +- B05_wf2_Route/B05_wf2_Route_Router_Confirm.py | 79 ++++++ .../B06_wf3_ProfileCross_Api_Fetch.ts | 4 + .../B06_wf3_ProfileCross_Router.py | 54 +++- .../B06_wf3_ProfileCross_Router_Confirm.py | 2 + .../B06_wf3_ProfileCross_Schema.py | 2 + .../B06_wf3_ProfileCross_UI_Cross_View.ts | 48 +++- .../B06_wf3_ProfileCross_UI_Page.ts | 245 +++++++++++++----- .../B06_wf3_ProfileCross_UI_Section_View.ts | 7 +- .../B06_wf3_ProfileCross_UI_Standard_Panel.ts | 6 +- .../B06_wf3_ProfileCross_UI_Style_Cross.css | 8 + config/config_system.py | 5 +- ui_template/ui_template_locale_b2.ts | 4 +- 13 files changed, 402 insertions(+), 79 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_Router.py b/B05_wf2_Route/B05_wf2_Route_Router.py index 0efb30c9..ca195d4e 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router.py +++ b/B05_wf2_Route/B05_wf2_Route_Router.py @@ -31,6 +31,7 @@ from B05_wf2_Route.B05_wf2_Route_Repository import ( from B05_wf2_Route.B05_wf2_Route_Router_Confirm import ( _append_irregular_cross_sections, _merge_uphill_overrides_into_longitudinal, + sync_uphill_overrides_into_designs, ) from B05_wf2_Route.B05_wf2_Route_Schema import ( GRADE_PERCENT_FIELDS, @@ -532,11 +533,23 @@ async def confirm_latest_route( connection, project_id, latest["id"] ) if longitudinal: + overrides = [item.model_dump() for item in request.uphill_overrides] + project_root = Path(resolve_stored_project_path(stored_path)) await asyncio.to_thread( _merge_uphill_overrides_into_longitudinal, - Path(resolve_stored_project_path(stored_path)), + project_root, str(longitudinal["longitudinal_file_path"]), - [item.model_dump() for item in request.uphill_overrides], + overrides, + ) + # 저장된 횡단 설계의 절토측·측구측도 새 방향으로 재계산 — 정본만 + # 바꾸면 B06 표시·역반영이 옛 방향을 고수한다(2026-08-06 13측점). + await sync_uphill_overrides_into_designs( + connection, + project_id, + latest["id"], + project_root, + str(longitudinal["longitudinal_file_path"]), + overrides, ) except Exception: logger.exception( diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Confirm.py b/B05_wf2_Route/B05_wf2_Route_Router_Confirm.py index 824ecfbe..1364db61 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router_Confirm.py +++ b/B05_wf2_Route/B05_wf2_Route_Router_Confirm.py @@ -72,6 +72,85 @@ def _merge_uphill_overrides_into_longitudinal( atomic_write_json(path, data) +async def sync_uphill_overrides_into_designs( + connection: aiomysql.Connection, + project_id: UUID, + route_id: int, + project_root: Path, + longitudinal_file_path: str, + overrides: list[dict[str, Any]], +) -> None: + """상단측 변경 측점의 **저장된 횡단 설계**를 새 방향으로 재계산해 저장한다. + + 종단 정본(stations.uphill_side)만 바꾸면 저장 설계(design.section_mode/ditch_side)가 + 옛 방향으로 남아 B06이 그것을 계속 표시하고, 종횡단 확정의 역반영 + (ditch_side→uphill_side)이 사용자 지정을 다시 옛 방향으로 되돌린다 + (2026-08-06 13측점 보고: uphill=right vs design=left_cut 순환 덮어쓰기). + 편절은 절토측을, 양절은 측구측을 새 방향으로 맞추고 기하를 즉시 재계산한다. + 양성(both_fill)은 측구가 없으므로 건드리지 않는다. + """ + # 지연 import — B06 라우터 모듈 로드는 이 함수가 실제 불릴 때만 필요하다. + from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Design import compute_cross_design + from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import ( + get_cross_section_designs, + update_cross_section_design, + ) + from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Router import _read_cross_design_inputs + + if not overrides: + return + by_chainage = {round(float(item["chainage_m"]), 3): str(item["side"]) for item in overrides} + designs = await get_cross_section_designs(connection, route_id) + # 표준단면 수치는 종단 정본 options에 병합 저장돼 있다(종횡단 확정 시) — 없으면 기본값. + longitudinal = await get_longitudinal_section(connection, project_id, route_id) + stored_standard = None + if longitudinal and isinstance(longitudinal.get("data"), dict): + options = longitudinal["data"].get("options") + if isinstance(options, dict): + stored_standard = options.get("standard_cross_section") + for record in designs: + chainage = round(float(record["chainage_m"]), 3) + side = by_chainage.get(chainage) + design = record.get("design") + if side is None or not isinstance(design, dict): + continue + mode = design.get("section_mode") + if mode == "both_fill": + continue + next_mode = f"{side}_cut" if mode in ("left_cut", "right_cut") else mode + next_ditch = side + if next_mode == mode and design.get("ditch_side") == next_ditch: + continue + samples, design_elevation, pavement_suggested = await asyncio.to_thread( + _read_cross_design_inputs, project_root, longitudinal_file_path, float(chainage) + ) + next_design = compute_cross_design( + samples, + design_elevation, + ground_type=str(design.get("ground_type", "soil")), + section_mode=str(next_mode), + ditch_side=next_ditch, + ditch_type=str(design.get("ditch_type", "standard")), + paved=bool(design.get("paved", False)), + standard=stored_standard, + rock_boundary_offset_m=design.get("rock_boundary_offset_m"), + two_stage_slope=bool(design.get("two_stage_slope", True)), + ditch_enabled=design.get("ditch_enabled"), + ) + next_design["status"] = design.get("status", "provisional") + next_design["pavement_suggested"] = design.get("pavement_suggested", pavement_suggested) + # 개별 표시 반폭 등 계산과 무관한 표시 설정은 그대로 이월한다. + if design.get("display_half_width_m") is not None: + next_design["display_half_width_m"] = design["display_half_width_m"] + await update_cross_section_design( + connection, + route_id=route_id, + chainage_m=float(chainage), + design=next_design, + project_id=project_id, + ) + + def _merge_irregular_into_longitudinal( project_root: Path, longitudinal_file_path: str, irregular_stations: list[dict[str, Any]] ) -> None: diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts index d30f065b..a172c1dd 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts @@ -265,6 +265,8 @@ export interface CrossDesign { design_line: Array<{ offset_m: number; elevation_m: number }>; /** 확정 시 병합되는 암 경계선 오프셋(m). 세션 값이 우선이며 복원 폴백으로 쓴다. */ rock_boundary_offset_m?: number; + /** 측점 개별 표시 반폭(m, 2026-08-06). 확정 시 병합되며 세션 값이 우선이다. */ + display_half_width_m?: number; } export interface CrossDesignResponse { @@ -296,6 +298,8 @@ export interface CrossDesignRequest { export interface CrossSectionPatch { chainage_m: number; rock_boundary_offset_m?: number; + /** 측점 개별 표시 반폭(m, 2026-08-06 사용자 지시). */ + display_half_width_m?: number; } /** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */ diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py index d50186dd..8929062d 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py @@ -12,6 +12,7 @@ from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B05_wf2_Route.B05_wf2_Route_Engine_Grade import GradeDesignOptions, resolve_grade_options from B05_wf2_Route.B05_wf2_Route_Engine_Grade_Profile import rebuild_alignment_profile from B05_wf2_Route.B05_wf2_Route_Engine_Sections import ( cross_filename, @@ -301,6 +302,39 @@ async def get_section_detail( ) +def _regeneration_grade_options(stage_params: dict[str, Any] | None) -> GradeDesignOptions | None: + """재생성 시 종단 계획선 기준을 확정 당시 저장 파라미터(stage 2)로 재구성한다. + + grade_options 없이 재생성하면 종단 계획선(profile_alignment)이 통째로 소실돼 계획 + 횡단도선·유토곡선·테이블이 사라진다(2026-08-06 사용자 보고 — 옛 재계산 버튼 사고의 + 원인). B04 파일입력→B06 자동 계산 파이프라인이 쓰는 같은 엔진 경로를 재활용한다. + """ + if not stage_params: + return None + options = stage_params.get("options") or {} + grade_class = options.get("grade_class") + if not grade_class: + return None + stored = { + key: stage_params.get(key) + for key in ( + "max_grade_pct", + "min_vertical_radius_m", + "min_tangent_length_m", + "balance_segment_length_m", + "start_elevation_offset_m", + "end_elevation_offset_m", + ) + if stage_params.get(key) is not None + } + return resolve_grade_options( + str(grade_class), + terrain_type=str(options.get("terrain_type") or "normal"), + paved=bool(options.get("paved", False)), + stored=stored, + ) + + def _regeneration_options( stored_options: dict[str, Any] | None, stage_params: dict[str, Any] | None, @@ -348,6 +382,7 @@ async def regenerate_sections( ) project_root = Path(resolve_stored_project_path(stored_path)) crs_epsg = source["crs_epsg"] + stage_params = route_stage.get("params") if route_stage else None sections = await asyncio.to_thread( run_section_generation, project_root, @@ -357,9 +392,11 @@ async def regenerate_sections( bool(surface_params["smooth"]), options=_regeneration_options( stored_options, - route_stage.get("params") if route_stage else None, + stage_params, request.cross_half_width_m, ), + # 계획선까지 함께 재계산·저장 — 없으면 계획 횡단도선·유토곡선이 사라진다. + grade_options=_regeneration_grade_options(stage_params), crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None, ) await connection.begin() @@ -555,6 +592,9 @@ def _recompute_designs_for_alignment( continue design["status"] = "provisional" design["pavement_suggested"] = suggested + # 표시 설정(측점 개별 반폭)은 계산과 무관 — 재계산이 지우면 안 된다(2026-08-06). + if stored.get("display_half_width_m") is not None: + design["display_half_width_m"] = stored["display_half_width_m"] section["design"] = design @@ -723,6 +763,18 @@ async def compute_cross_section_design( # 법정 근거 문구 표기용 — 사용자가 포장을 바꿔도 제안 여부는 그대로 남긴다. design["pavement_suggested"] = pavement_suggested async with pool.acquire() as connection: + # 표시 설정(측점 개별 반폭)은 계산 입력이 아니다 — 저장분에서 이월해 재계산이 + # 지우지 않게 한다(2026-08-06). + stored_designs = await get_cross_section_designs(connection, route_id) + for record in stored_designs: + if abs(float(record["chainage_m"]) - request.chainage_m) < 0.01: + stored_design = record.get("design") + if ( + isinstance(stored_design, dict) + and stored_design.get("display_half_width_m") is not None + ): + design["display_half_width_m"] = stored_design["display_half_width_m"] + break await connection.begin() try: updated = await update_cross_section_design( diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router_Confirm.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router_Confirm.py index a51c8d13..ddc740d6 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router_Confirm.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router_Confirm.py @@ -76,6 +76,8 @@ async def _apply_section_edits( patch: dict[str, Any] = {} if patch_item.rock_boundary_offset_m is not None: patch["rock_boundary_offset_m"] = patch_item.rock_boundary_offset_m + if patch_item.display_half_width_m is not None: + patch["display_half_width_m"] = patch_item.display_half_width_m if patch: await merge_cross_section_design_patch( connection, route_id=route_id, chainage_m=patch_item.chainage_m, patch=patch diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py index 8af0ac84..f0425287 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py @@ -72,6 +72,8 @@ class CrossSectionPatch(BaseModel): chainage_m: float = Field(..., ge=0) # 암 경계선 오프셋(m, 지면선 기준 하향 음수 — 계획선 아님). 암 지반 측점만 의미 있다. rock_boundary_offset_m: float | None = None + # 측점별 표시 반폭(m) — 카드 개별 조절값. 전역 반폭과 다를 때만 실린다(2026-08-06). + display_half_width_m: float | None = Field(default=None, gt=0) class SectionConfirmRequest(BaseModel): diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts index 7044655f..2db3d66c 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts @@ -262,6 +262,17 @@ function buildZoomControls(handle: ZoomPanHandle): HTMLElement { return bar; } +/** + * 측점 **개별 표시 반폭** 세션 제어기(2026-08-06 사용자 지시). + * Page가 세션 보관·저장값 복원·카드 갱신·확정 저장(cross_patches)을 연결해 구현한다. + */ +export interface StationWidthControl { + /** 개별값(세션→저장값) 우선. 없으면 undefined — 카드가 전역 반폭으로 그린다. */ + widthFor: (section: CrossSection) => number | undefined; + adjust: (chainageM: number, deltaM: number) => void; + reset: (chainageM: number) => void; +} + export function createCrossSectionCard( section: CrossSection, selected: boolean, @@ -278,7 +289,11 @@ export function createCrossSectionCard( initialAreaKey?: CrossAreaKey | null, /** 면적 값을 눌렀을 때. 선택되지 않은 카드에서도 눌릴 수 있어 측점 id를 함께 넘긴다. */ onAreaSelect?: (stationId: string, key: CrossAreaKey | null) => void, + /** 개별 표시 반폭 제어 — 있으면 카드 하단에 ◀/▶/↺ 버튼 그룹을 우측 맞춤으로 단다. */ + stationWidth?: StationWidthControl, ): CrossCardElement { + // 이 카드의 실효 표시 반폭 — 개별값이 전역 반폭보다 우선한다(2026-08-06). + const effectiveHalfWidth = stationWidth?.widthFor(section) ?? crossHalfWidth; const card: CrossCardElement = document.createElement("article"); card.id = `cross-${section.station_id}`; card.className = `b06-cross-card${selected ? " b06-cross-card--selected" : ""}`; @@ -366,7 +381,7 @@ export function createCrossSectionCard( section, verticalExaggeration, widthPx, - crossHalfWidth, + effectiveHalfWidth, designElevation, forcedHeightPx, ); @@ -564,17 +579,40 @@ export function createCrossSectionCard( const footer = document.createElement("footer"); const center = document.createElement("span"); center.textContent = `${L("B06_Profile_View_CenterElevation")} ${section.center_z?.toFixed(2) ?? "-"}m`; - const azimuth = document.createElement("span"); - azimuth.textContent = `${L("B06_Profile_View_Azimuth")} ${section.azimuth_deg?.toFixed(1) ?? "-"}°`; footer.append(center); - // 암 경계선 제어는 그래프 안이 아니라 **중심고·방위각 행 가운데**에 둔다 + // 암 경계선 제어는 그래프 안이 아니라 **하단 정보 행 가운데**에 둔다 // (2026-08-02 사용자 지시). 그래프 안에 있으면 도면 위에 겹쳐 단면을 가렸다. 암 지반만. + // 방위각 표기는 볼 일이 없어 삭제했다(2026-08-06 사용자 지시). if (rockBoundary && section.design?.geometry_preset === "rock") { const rockControl = buildRockBoundaryControl(section, rockBoundary); rockControl.classList.add("b06-cross-card__rockb"); footer.append(rockControl); } - footer.append(azimuth); + // 개별 표시 반폭 ◀/▶/↺ — 암 경계 그룹 우측에 우측 맞춤(2026-08-06 사용자 지시). + // 숫자 표시는 두지 않고, 초기화는 전역 반폭으로 되돌린다. + if (stationWidth) { + const widthControl = document.createElement("div"); + widthControl.className = "b06-cross-card__widthctl"; + const makeButton = (label: string, title: string, onClick: () => void): HTMLButtonElement => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "b06-design__rockb-btn"; + button.textContent = label; + button.title = title; + button.addEventListener("click", (event) => { + // 카드 선택 클릭으로 번지면 재렌더로 줌·팬이 초기화된다. + event.stopPropagation(); + onClick(); + }); + return button; + }; + widthControl.append( + makeButton("◀", L("B06_Cross_Width_Dec"), () => stationWidth.adjust(section.chainage_m, -1)), + makeButton("▶", L("B06_Cross_Width_Inc"), () => stationWidth.adjust(section.chainage_m, 1)), + makeButton("↺", L("B06_Cross_Width_Reset"), () => stationWidth.reset(section.chainage_m)), + ); + footer.append(widthControl); + } card.append(footer); return card; } diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index 21038b71..cb810379 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -22,6 +22,7 @@ import { saveSections, type CrossSectionPatch, fetchSectionContext, + fetchSectionDetail, getSections, previewCrossDesigns, regenerateSections, @@ -29,6 +30,7 @@ import { type SectionDetailResponse, type StandardCrossSection, } from "./B06_wf3_ProfileCross_Api_Fetch"; +import { type StationWidthControl } from "./B06_wf3_ProfileCross_UI_Cross_View"; import { readAlignmentDraft } from "../B05_wf2_Route/B05_wf2_Route_UI_Profile_Edit"; import { type CrossDesignChange, @@ -77,29 +79,19 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { standardGroup.append(standardPanelSlot); let standardPanel: StandardPanelController | null = null; - const displayGroup = buildGroup(L("B06_Profile_Group_Display")); - const verticalExaggerationField = createInputField({ - label: L("B06_Profile_Field_VerticalExaggeration"), - type: "number", - }); - verticalExaggerationField.input.min = "0.1"; - verticalExaggerationField.input.step = "0.1"; + // 높이 배율 옵션은 폐지 — 항상 1배율(2026-08-06 사용자 지시). 표시 옵션 그룹도 함께 + // 사라지고, 횡단 반폭은 표준 횡단면 설정 컨테이너의 [전체 측점 반영] 위로 옮겼다. + // 반폭 적용도 그 버튼이 담당한다(재계산 버튼 폐지) — 요청 반폭이 보유 샘플 폭(기본 + // 20m, config SECTION_CROSS_HALF_WIDTH_M) 이하면 표시만 바꾸고, 넘으면 B05부터 + // 재생성해 영구저장 후 다시 로드한다. const crossHalfWidthField = createInputField({ label: L("B05_Route_Field_CrossHalfWidth"), type: "number", }); crossHalfWidthField.input.min = "0.1"; crossHalfWidthField.input.step = "0.1"; - displayGroup.append(crossHalfWidthField.root, verticalExaggerationField.root); - const recalcButton = createButton({ - label: L("B06_Profile_Btn_Recalc"), - variant: "ghost", - onClick: () => void applyCrossHalfWidth(), - }); - recalcButton.disabled = true; // 임시 저장 — 확정과 같은 내용을 남기되 페이지 이동이 없다(2026-08-02 사용자 지시). - // 자리는 재계산과 확정 사이(1행 3열). const saveButton = createButton({ label: L("B06_Profile_Btn_Save"), variant: "ghost", @@ -116,11 +108,11 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { const actionRow = document.createElement("div"); // 사이드 최하단 고정(공용 ui-sidebar-actions) — 스크롤에서 제외(2026-08-05 사용자 지시). actionRow.className = "b06-profile__actions ui-sidebar-actions"; - actionRow.append(recalcButton, saveButton, confirmButton); + actionRow.append(saveButton, confirmButton); const leftForm = document.createElement("div"); leftForm.className = "b06-profile__form"; - leftForm.append(standardGroup, displayGroup, actionRow); + leftForm.append(standardGroup, actionRow); // 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님. attachCollapsible(leftForm); @@ -263,9 +255,36 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { /** 패널 [전체 반영](N-2-1): design 보유 전 측점을 패널 최신값으로 순차 재계산한다. * handleDesignChange가 standardPanel.getValues()를 실어 보내므로 표준단면 수치만 - * 갱신되고 측점별 버튼 선택값은 보존된다. 순차 await로 동시 API 호출 수를 제한한다. */ + * 갱신되고 측점별 버튼 선택값은 보존된다. 순차 await로 동시 API 호출 수를 제한한다. + * + * 횡단 반폭 적용도 여기서 한다(2026-08-06 사용자 지시 — 재계산 버튼 폐지): + * 요청 반폭 ≤ 보유 샘플 폭 → 표시 범위만 바뀌므로 재렌더로 끝(재계산 없음). + * 요청 반폭 > 보유 샘플 폭 → B05부터 재생성해 영구 저장 후 상세를 다시 로드한다 + * (기본 설계는 상세 조회가 자동으로 얹는다. 느려도 허용 — 2026-08-06 사용자 확정). */ async function applyPanelToAll(): Promise { - if (!sectionDetail) return; + if (!sectionDetail || !projectId || currentRouteId === null) return; + const requested = crossHalfWidth(); + if (requested !== undefined && requested > sampledHalfWidth() + 1e-6) { + showLoadingOverlay(); + try { + await regenerateSections(projectId, currentRouteId, requested); + // 재생성 응답에는 설계가 없다 — 상세를 다시 받아 기본 설계 프리뷰까지 얹는다. + const fresh = await fetchSectionDetail(projectId, currentRouteId); + sectionDetail = fresh; + // 공유 캐시도 새 상세로 바꿔 B05가 옛 값을 못 보게 한다. + replaceSectionDetail(projectId, currentRouteId, fresh); + showToast(L("B06_Profile_Regenerate_Success"), "success"); + } catch (error) { + const detail = error instanceof Error ? ` ${error.message}` : ""; + showToast(`${L("B06_Profile_Regenerate_Failed")}${detail}`, "error"); + hideLoadingOverlay(); + return; + } + hideLoadingOverlay(); + } + persistDisplayHalfWidth(); + renderSectionDetail(); + updateActionState(); const targets = sectionDetail.cross_sections.filter((section) => section.design); if (!targets.length) return; showLoadingOverlay(); @@ -355,9 +374,84 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { }, }; - const sectionView = createSectionView((chainageM, change) => { - void handleDesignChange(chainageM, change); - }, rockBoundaryControl); + /* ── 측점 개별 표시 반폭(2026-08-06 사용자 지시) ────────────────────── + * 카드 하단 ◀/▶/↺으로 1m씩 조절. 세션에 보관했다가 종/횡단 확정·임시저장 때 + * cross_patches(design.display_half_width_m)로 영구 저장돼 재접근 시 유지된다. + * 값 우선순위: 세션 → 저장값(design) → 없음(전역 반폭). */ + const stationWidths = new Map(); + const widthKey = (chainageM: number): string => chainageM.toFixed(2); + const widthSessionKey = (): string | null => + projectId && currentRouteId !== null ? `b06:crossw:${projectId}:${currentRouteId}` : null; + + function loadStationWidths(): void { + stationWidths.clear(); + const key = widthSessionKey(); + if (!key) return; + try { + const raw = window.sessionStorage.getItem(key); + if (!raw) return; + const parsed = JSON.parse(raw) as Record; + Object.entries(parsed).forEach(([chainage, width]) => { + if (Number.isFinite(width) && width > 0) stationWidths.set(chainage, width); + }); + } catch { + /* 손상된 세션 값은 무시 — 저장값·전역 반폭으로 재시작. */ + } + } + + function persistStationWidths(): void { + const key = widthSessionKey(); + if (!key) return; + try { + window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(stationWidths))); + } catch { + /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ + } + } + + /** 개별 반폭 하한 2m·상한은 보유 샘플 폭 — 표시용이라 샘플 밖은 의미가 없다. */ + const clampStationWidth = (value: number): number => + Math.min(Math.max(value, 2), Math.max(sampledHalfWidth(), 2)); + + const stationWidthControl: StationWidthControl = { + widthFor: (section) => { + const session = stationWidths.get(widthKey(section.chainage_m)); + if (session !== undefined) return session; + const stored = section.design?.display_half_width_m; + return typeof stored === "number" && stored > 0 ? stored : undefined; + }, + adjust: (chainageM, deltaM) => { + const key = widthKey(chainageM); + const section = sectionDetail?.cross_sections.find( + (entry) => Math.abs(entry.chainage_m - chainageM) < 0.01, + ); + const stored = section?.design?.display_half_width_m; + const current = + stationWidths.get(key) ?? + (typeof stored === "number" && stored > 0 ? stored : undefined) ?? + crossHalfWidth() ?? + sampledHalfWidth(); + stationWidths.set(key, clampStationWidth(Math.round(current + deltaM))); + persistStationWidths(); + sectionView.refreshCard(chainageM); + }, + reset: (chainageM) => { + // 초기화 = 전역 반폭 복귀. 저장값(design)도 무시해야 하므로 세션에 전역값을 명시한다. + const globalWidth = crossHalfWidth(); + if (globalWidth === undefined) stationWidths.delete(widthKey(chainageM)); + else stationWidths.set(widthKey(chainageM), clampStationWidth(globalWidth)); + persistStationWidths(); + sectionView.refreshCard(chainageM); + }, + }; + + const sectionView = createSectionView( + (chainageM, change) => { + void handleDesignChange(chainageM, change); + }, + rockBoundaryControl, + stationWidthControl, + ); // 메인 영역: 종·횡단 도면(sectionView) 또는 안내 메시지를 표시한다. const mainArea = document.createElement("div"); @@ -375,34 +469,51 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { mainArea.replaceChildren(text); } - function verticalExaggeration(): number { - const parsed = Number(verticalExaggerationField.input.value); - return Number.isFinite(parsed) && parsed >= 0.1 ? parsed : 1; - } - function crossHalfWidth(): number | undefined { const parsed = Number(crossHalfWidthField.input.value); return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; } - let appliedHalfWidth: number | undefined; + /** 보유 샘플의 최대 반폭(m) — 이 폭 안에서는 표시만 바꾸면 되고, 넘으면 재생성이 필요하다. */ + function sampledHalfWidth(): number { + if (!sectionDetail) return 0; + return Math.max( + 0, + ...sectionDetail.cross_sections.flatMap((section) => + section.samples.map((sample) => Math.abs(sample.offset_m ?? 0)), + ), + ); + } - /** 반폭 미적용 상태에서는 [재계산]만 활성, 적용 완료 상태에서는 [확정]만 활성. */ - function updateActionState(): void { + /** 표시 반폭 세션 키 — 페이지를 떠났다 와도 조절값이 유지되게 한다. */ + const displaySessionKey = (): string | null => + projectId && currentRouteId !== null + ? `b06:cross-display:${projectId}:${currentRouteId}` + : null; + + function persistDisplayHalfWidth(): void { + const key = displaySessionKey(); const width = crossHalfWidth(); - const stale = sectionDetail !== null && width !== undefined && width !== appliedHalfWidth; - recalcButton.disabled = !stale; - // 임시 저장은 재계산이 밀려 있어도 눌릴 수 있어야 한다 — 지금까지 편집분을 잃지 않는 게 목적이다. + if (!key || width === undefined) return; + try { + window.sessionStorage.setItem(key, String(width)); + } catch { + /* 무시 */ + } + } + + function updateActionState(): void { saveButton.disabled = sectionDetail === null; - confirmButton.disabled = sectionDetail === null || stale; + confirmButton.disabled = sectionDetail === null; } function renderSectionDetail(): void { if (sectionDetail) { showSectionView(); + // 높이 배율은 항상 1(2026-08-06 사용자 지시 — 옵션 폐지). sectionView.render( sectionDetail, - verticalExaggeration(), + 1, crossHalfWidth(), stationInterval, context?.earthwork_conversion, @@ -413,40 +524,28 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { } } - async function applyCrossHalfWidth(): Promise { - const width = crossHalfWidth(); - if (!projectId || currentRouteId === null || width === undefined) return; - showLoadingOverlay(); - try { - sectionDetail = await regenerateSections(projectId, currentRouteId, width); - // 서버가 정본을 통째로 다시 썼다 — 공유 캐시도 새 상세로 바꿔 B05가 옛 값을 못 보게 한다. - replaceSectionDetail(projectId, currentRouteId, sectionDetail); - appliedHalfWidth = width; - renderSectionDetail(); - showToast(L("B06_Profile_Regenerate_Success"), "success"); - } catch (error) { - const detail = error instanceof Error ? ` ${error.message}` : ""; - showToast(`${L("B06_Profile_Regenerate_Failed")}${detail}`, "error"); - } finally { - hideLoadingOverlay(); - updateActionState(); - } - } - - verticalExaggerationField.input.addEventListener("input", renderSectionDetail); - crossHalfWidthField.input.addEventListener("input", updateActionState); - /** 확정과 임시 저장이 함께 보내는 편집분(암 경계선 오프셋 + 유토곡선 + balloon 위치). */ function collectSectionEdits(): { crossPatches: CrossSectionPatch[]; massHaul: Record | undefined; } { - const crossPatches: CrossSectionPatch[] = [...rockOffsets.entries()].map( - ([chainage, offset]) => ({ - chainage_m: Number(chainage), - rock_boundary_offset_m: offset, - }), - ); + // 암 경계 오프셋 + 측점 개별 표시 반폭을 chainage 기준으로 합쳐 한 패치로 보낸다. + const patchByChainage = new Map(); + const patchFor = (chainageM: number): CrossSectionPatch => { + const existing = patchByChainage.get(chainageM); + if (existing) return existing; + const created: CrossSectionPatch = { chainage_m: chainageM }; + patchByChainage.set(chainageM, created); + return created; + }; + rockOffsets.forEach((offset, chainage) => { + patchFor(Number(chainage)).rock_boundary_offset_m = offset; + }); + // 개별 표시 반폭(2026-08-06) — 확정·임시저장 시 design에 병합돼 재접근 시 유지된다. + stationWidths.forEach((width, chainage) => { + patchFor(Number(chainage)).display_half_width_m = width; + }); + const crossPatches: CrossSectionPatch[] = [...patchByChainage.values()]; // 유토곡선은 화면 표시 내내 프론트 메모리에만 있다가 저장 시점에만 영구 저장된다. const result = sectionDetail && context?.earthwork_conversion @@ -549,14 +648,19 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { return; } - verticalExaggerationField.input.value = String(context.defaults.vertical_exaggeration); crossHalfWidthField.input.value = String(context.defaults.cross_half_width_m); stationInterval = context.defaults.station_interval_m; rockBoundaryDefault = context.rock_boundary_default_offset_m; rockBoundaryStep = context.rock_boundary_step_m; // 표준 횡단면 설정 패널 장착(세션값 우선, 없으면 config 기본값). - standardPanel = createStandardPanel(projectId, context.standard_cross_section, applyPanelToAll); + // 횡단 반폭 입력은 [전체 측점 반영] 버튼 위로 들어간다(2026-08-06 사용자 지시). + standardPanel = createStandardPanel( + projectId, + context.standard_cross_section, + applyPanelToAll, + crossHalfWidthField.root, + ); standardPanelSlot.append(standardPanel.root); if (context.route_id === null) { @@ -566,6 +670,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { currentRouteId = context.route_id; loadRockOffsets(); + loadStationWidths(); try { const existing = await getSections(projectId, context.route_id); if (!existing.longitudinal) { @@ -596,9 +701,15 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { ), ); if (storedHalfWidth > 0) crossHalfWidthField.input.value = storedHalfWidth.toFixed(1); + // 세션에 보관된 표시 반폭이 있으면 그것이 우선한다(사용자가 마지막으로 지정한 값). + const sessionDisplayKey = displaySessionKey(); + if (sessionDisplayKey) { + const sessionDisplay = Number(window.sessionStorage.getItem(sessionDisplayKey)); + if (Number.isFinite(sessionDisplay) && sessionDisplay > 0) + crossHalfWidthField.input.value = sessionDisplay.toFixed(1); + } if (storedOptions?.station_interval_m && storedOptions.station_interval_m > 0) stationInterval = storedOptions.station_interval_m; - appliedHalfWidth = crossHalfWidth(); renderSectionDetail(); void reconcileStaleDesigns(); // 옛 암 2단계 + 종단 변경 반영 자동 재계산(E-1 + N-6) updateActionState(); diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts index fd42a043..e665f2ad 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts @@ -29,6 +29,7 @@ import { createCrossSectionCard, crossCardNaturalHeight, type CrossCardElement, + type StationWidthControl, } from "./B06_wf3_ProfileCross_UI_Cross_View"; import { createLongitudinalProfile, @@ -170,6 +171,8 @@ export interface SectionViewController { export function createSectionView( onDesignChange?: DesignChangeHandler, rockBoundary?: RockBoundaryControl, + /** 측점 개별 표시 반폭 제어(2026-08-06) — 카드 하단 ◀/▶/↺과 행 높이 계산이 쓴다. */ + stationWidth?: StationWidthControl, ): SectionViewController { const root = document.createElement("div"); root.className = "b06-section"; @@ -439,6 +442,7 @@ export function createSectionView( rockBoundary, section.station_id === selectedStationId ? activeAreaKey : null, selectArea, + stationWidth, ); /** 범례 버튼 — 곡선 하나를 켜고 끈다. 축은 전체 곡선 기준이라 여기서 움직이지 않는다. */ @@ -620,7 +624,8 @@ export function createSectionView( section, currentExaggeration, cachedCardWidth, - currentCrossHalfWidth, + // 개별 표시 반폭이 있으면 그 폭 기준으로 높이를 재야 카드·행 높이가 맞는다. + stationWidth?.widthFor(section) ?? currentCrossHalfWidth, designElevationAt(detail.longitudinal.design_profiles, section.chainage_m), ), ); diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Panel.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Panel.ts index 0d6c5c55..43f0e349 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Panel.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Panel.ts @@ -159,6 +159,8 @@ export function createStandardPanel( projectId: string, defaults: StandardCrossSection, onApplyAll?: () => void | Promise, + /** [전체 측점 반영] 버튼 **위**에 끼울 추가 컨트롤(횡단 반폭 입력, 2026-08-06 사용자 지시). */ + extraControl?: HTMLElement, ): StandardPanelController { // 세션값 우선, 없으면 config 기본값. defaults는 복원 기준으로 보존한다. const sessionValue = readSession(projectId); @@ -262,7 +264,9 @@ export function createStandardPanel( } actions.append(resetButton); - root.append(body, loader, actions); + // 추가 컨트롤(횡단 반폭)은 액션 행 바로 위 — [전체 측점 반영]이 반폭 적용까지 담당한다. + if (extraControl) root.append(body, loader, extraControl, actions); + else root.append(body, loader, actions); return { root, diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css index ad7c6d97..fcd9b7fc 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css @@ -548,6 +548,14 @@ padding: 1px 4px; } +/* 개별 표시 반폭 ◀/▶/↺ — 암 경계 그룹 우측, 행 끝에 우측 맞춤(2026-08-06 사용자 지시). */ +.b06-cross-card__widthctl { + display: flex; + flex: 0 0 auto; + gap: 2px; + margin-left: auto; +} + /* 줌 버튼 — 그래프 우측 상단 오버레이. 휠 대신 쓰는 조작구다(2026-08-02 사용자 확정). */ .b06-cross-card__zoom { position: absolute; diff --git a/config/config_system.py b/config/config_system.py index d6acb9d9..947c604d 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -387,7 +387,10 @@ STRUCTURE_ETC_DEFAULT_NAME = "기타 구조물" # 5-4. 종횡단 생성 파라미터 (B06 WF3) # ───────────────────────────────────────────────────────────────────────── SECTION_STATION_INTERVAL_M = float(os.getenv("SECTION_STATION_INTERVAL_M", "20.0")) -SECTION_CROSS_HALF_WIDTH_M = float(os.getenv("SECTION_CROSS_HALF_WIDTH_M", "15.0")) +# 초기 파이프라인(파일입력→B06) 샘플 반폭 — 표시 반폭보다 넉넉히 뽑아 두면 사용자가 +# 표시 반폭을 이 안에서 바꿀 때 재계산이 필요 없다(2026-08-06 사용자 확정, 기준 20m). +# 표시 반폭이 이 값을 넘을 때만 B05부터 재생성한다. 향후 사용자 평균 설정 보고 조정. +SECTION_CROSS_HALF_WIDTH_M = float(os.getenv("SECTION_CROSS_HALF_WIDTH_M", "20.0")) SECTION_CROSS_SAMPLE_INTERVAL_M = float(os.getenv("SECTION_CROSS_SAMPLE_INTERVAL_M", "0.5")) SECTION_LONG_SAMPLE_INTERVAL_M = float(os.getenv("SECTION_LONG_SAMPLE_INTERVAL_M", "1.0")) SECTION_VERTICAL_EXAGGERATION = float(os.getenv("SECTION_VERTICAL_EXAGGERATION", "1.0")) diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index dbc21766..1327cd62 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -198,7 +198,9 @@ export const ui_locales_b2 = { "횡단 반폭을 반영해 종·횡단을 재생성했습니다.", "Sections regenerated with the new half-width.", ], - B06_Profile_Btn_Recalc: ["재계산", "Recalculate"], + B06_Cross_Width_Dec: ["표시 반폭 1m 줄이기", "Narrow view width by 1m"], + B06_Cross_Width_Inc: ["표시 반폭 1m 늘리기", "Widen view width by 1m"], + B06_Cross_Width_Reset: ["전역 반폭으로 초기화", "Reset to global width"], B06_Profile_View_Longitudinal: ["종단면도", "Longitudinal profile"], B06_Profile_View_Cross: ["횡단면도", "Cross sections"], B06_Profile_View_StationCount: ["횡단 측점", "Cross stations"],