From e4700546bb9381f41f72da5451922c82c4313542 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 2 Aug 2026 18:29:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=EB=9D=A0=20=EC=99=B8=EA=B3=BD?= =?UTF-8?q?=EC=84=A0=20=EC=9E=AC=EC=A0=95=EC=9D=98=20=C2=B7=20balloon=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=9D=B4=ED=83=88=20=EB=B0=A9=EC=A7=80=20?= =?UTF-8?q?=C2=B7=20=EC=9E=84=EC=8B=9C=20=EC=A0=80=EC=9E=A5=20=C2=B7=20?= =?UTF-8?q?=ED=86=A0=EC=B7=A8=20=ED=99=94=EC=82=B4=ED=91=9C=20=EB=B3=B5?= =?UTF-8?q?=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [1] 띠 면이 스스로 겹쳤다. 외곽선을 "아래 현 -> 왼쪽 곡선 -> 위 현 -> 오른쪽 곡선"으로 이어 붙이다 보니, 곡선이 두 현 사이에서 요동치면 그 조각이 띠 밖으로 삐져나가 다각형이 자기교차했다. 정의를 바꿨다 - 띠는 "아래 현의 두 끝 사이에서, 아래 높이부터 곡선과 위 높이 중 안쪽까지"다. 아래 현을 밑변으로 깔고 곡선값을 두 높이 사이로 자른 값만 찍으면 어떤 지형에서도 단순 다각형이라 겹칠 수 없다. [2] balloon이 화면 밖으로 나갔다. 두 곳이 샜다 - 빈자리를 못 찾았을 때 findSlot이 세로를 자르지 않았고, 저장해 둔 이동량을 그릴 때도 자르지 않아 화면을 줄이면 밖을 가리켰다. 둘 다 상자 안으로 자르고, 드래그 중에도 같은 함수를 쓴다. [3] 임시 저장 - POST .../sections/{route_id}/save 신설. 저장 내용은 확정과 같고(표준횡단 설정·유토곡선·balloon 위치·암 경계선) 경로 상태·워크플로 단계를 건드리지 않아 페이지 이동이 없다. 미지정 측점을 기본값으로 채우지도 않는다. 버튼은 재계산과 확정 사이(1행 3열) 이고, 재계산이 밀려 있어도 눌린다 - 편집분을 잃지 않는 것이 목적이다. [4] 토취·사토 화살표를 실제 단차 끝까지 되돌렸다. 끝나는 높이가 새 평형선이라 그 자체가 정보다. 라벨이 곡선을 가리는 문제는 이미 balloon처럼 떼어 놓아 해결됐다. _Router.py(718줄) -> 저장·확정을 _Router_Confirm.py(203줄)로 분리. 임시 저장과 확정이 저장 본체(_apply_section_edits)를 공유한다. Co-Authored-By: Claude Opus 5 --- .../B06_wf3_ProfileCross_Api_Fetch.ts | 21 ++ .../B06_wf3_ProfileCross_Router.py | 113 ---------- .../B06_wf3_ProfileCross_Router_Confirm.py | 203 ++++++++++++++++++ ...06_wf3_ProfileCross_UI_MassHaul_Balance.ts | 64 +++--- ...3_ProfileCross_UI_MassHaul_Balance_View.ts | 47 +++- ...06_wf3_ProfileCross_UI_MassHaul_Balloon.ts | 19 +- .../B06_wf3_ProfileCross_UI_Page.ts | 96 ++++++--- main.py | 4 + ui_template/ui_template_locale_b2.ts | 8 + 9 files changed, 387 insertions(+), 188 deletions(-) create mode 100644 B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router_Confirm.py diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts index 19f31a4e..2e6bd845 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts @@ -401,6 +401,27 @@ export async function confirmSections( }); } +/** + * 편집 중인 종·횡단을 **확정하지 않고** 영구저장소에만 남긴다(임시 저장). + * 저장 내용은 확정과 같지만 경로 상태·워크플로 단계를 건드리지 않아 페이지 이동도 없다. + */ +export async function saveSections( + projectId: string, + routeId: number, + standardCrossSection?: StandardCrossSection, + crossPatches?: CrossSectionPatch[], + massHaul?: Record, +): Promise { + const body: Record = {}; + if (standardCrossSection) body.standard_cross_section = standardCrossSection; + if (crossPatches?.length) body.cross_patches = crossPatches; + if (massHaul) body.mass_haul = massHaul; + return requestJson(`/projects/${projectId}/sections/${routeId}/save`, { + method: "POST", + body: JSON.stringify(body), + }); +} + /** 같은 회사에서 표준횡단 설계값을 불러올 수 있는 프로젝트 항목. */ export interface CompanyStandardProject { project_id: string; diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py index 3cddc6e7..b9ed8728 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py @@ -603,116 +603,3 @@ async def compute_cross_section_design( status_code=500, content={"status": "error", "message": "횡단 설계 계산 중 오류가 발생했습니다."}, ) - - -@router.post("/{project_id}/sections/{route_id}/confirm", response_model=SectionConfirmResponse) -async def confirm_sections( - project_id: UUID, - route_id: int, - request: SectionConfirmRequest | None = Body(default=None), -) -> SectionConfirmResponse | JSONResponse: - """경로의 종횡단면을 확정(CONFIRMED)한다. - - 지반유형을 지정하지 않은 측점은 기본값(토사/좌절토)으로 자동 채운 뒤 확정한다. - 표준 횡단면 설정값이 함께 오면 longitudinal_sections.data.options에 저장한다. - """ - pool = get_db_pool() - try: - async with pool.acquire() as connection: - existing = await get_longitudinal_section(connection, project_id, route_id) - if not existing: - return JSONResponse( - status_code=404, - content={"status": "error", "message": "확정할 종횡단이 없습니다."}, - ) - stored_path = await get_project_storage_relative_path(connection, project_id) - missing = await get_cross_sections_missing_design_chainages(connection, route_id) - - # 미지정 측점을 기본값으로 계산해 채운다 (계산 불가 측점은 조용히 건너뜀). - default_designs: list[tuple[float, dict[str, Any]]] = [] - if missing: - project_root = Path(resolve_stored_project_path(stored_path)) - default_designs = await asyncio.to_thread( - _compute_default_designs, - project_root, - str(existing["longitudinal_file_path"]), - missing, - request.standard_cross_section if request else None, - ) - - async with pool.acquire() as connection: - await connection.begin() - try: - for chainage_m, design in default_designs: - await update_cross_section_design( - connection, - route_id=route_id, - chainage_m=chainage_m, - design=design, - project_id=project_id, - ) - if request and request.standard_cross_section: - await merge_longitudinal_section_options( - connection, - route_id=route_id, - options_patch={"standard_cross_section": request.standard_cross_section}, - ) - # 유토곡선 결과는 생성 옵션이 아니므로 data.options가 아니라 최상위 키에 둔다. - if request and request.mass_haul: - await merge_longitudinal_section_data( - connection, - route_id=route_id, - data_patch={"mass_haul": request.mass_haul}, - ) - # 프론트 세션 보관값(암 경계선 오프셋 등)을 측점별 design에 병합. - if request and request.cross_patches: - for patch_item in request.cross_patches: - 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: - await merge_cross_section_design_patch( - connection, - route_id=route_id, - chainage_m=patch_item.chainage_m, - patch=patch, - ) - await confirm_sections_for_route(connection, route_id) - async with connection.cursor() as cursor: - await complete_stage(cursor, str(project_id), 3) - await connection.commit() - except Exception: - await connection.rollback() - raise - - # 측구 방향(design.ditch_side) 변경을 B05 종단 정본 stations.uphill_side에 역반영한다(E-7). - # 파일 기반·비치명적: 실패해도 확정은 유지한다. - try: - async with pool.acquire() as connection: - designs = await get_cross_section_designs(connection, route_id) - overrides = [ - {"chainage_m": record["chainage_m"], "side": record["design"]["ditch_side"]} - for record in designs - if isinstance(record.get("design"), dict) - and record["design"].get("ditch_side") in ("left", "right") - ] - if overrides: - await asyncio.to_thread( - _merge_uphill_overrides_into_longitudinal, - Path(resolve_stored_project_path(stored_path)), - str(existing["longitudinal_file_path"]), - overrides, - ) - except Exception: - logger.exception( - "B06 측구 방향 B05 역반영 실패 (확정은 유지): project_id=%s route_id=%s", - project_id, - route_id, - ) - return SectionConfirmResponse(project_id=str(project_id), route_id=route_id) - except Exception: - logger.exception("B06 종횡단 확정 실패: project_id=%s", project_id) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "종횡단 확정 처리 중 오류가 발생했습니다."}, - ) diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router_Confirm.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router_Confirm.py new file mode 100644 index 00000000..a51c8d13 --- /dev/null +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router_Confirm.py @@ -0,0 +1,203 @@ +"""B06 종횡단 **저장·확정** 라우터. + +`_Router.py`가 700줄을 넘겨 조회·계산(그쪽)과 저장·확정(여기)을 갈랐다. + +임시 저장과 확정은 **저장하는 내용이 같다**(표준횡단 설정 · 유토곡선 · 측점별 암 경계선). +다른 것은 뒤처리뿐이다 — 확정만 미지정 측점을 기본값으로 채우고, 경로 상태를 CONFIRMED로 +바꾸고, 워크플로 단계를 닫고, 측구 방향을 B05 종단 정본에 역반영한다. 그래서 공통 저장을 +`_apply_section_edits()` 하나로 두고 두 엔드포인트가 함께 쓴다. +""" + +import asyncio +import logging +from pathlib import Path +from typing import Any +from uuid import UUID + +import aiomysql +from fastapi import APIRouter, Body +from fastapi.responses import JSONResponse + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B05_wf2_Route.B05_wf2_Route_Router_Confirm import _merge_uphill_overrides_into_longitudinal +from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import ( + confirm_sections_for_route, + get_cross_section_designs, + get_cross_sections_missing_design_chainages, + get_longitudinal_section, + merge_cross_section_design_patch, + merge_longitudinal_section_data, + merge_longitudinal_section_options, + update_cross_section_design, +) +from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Router import _compute_default_designs +from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import ( + SectionConfirmRequest, + SectionConfirmResponse, +) +from common_util.common_util_storage import resolve_stored_project_path +from common_util.common_util_workflow_state import complete_stage +from config.config_db import get_db_pool + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"]) + + +async def _apply_section_edits( + connection: aiomysql.Connection, + route_id: int, + request: SectionConfirmRequest | None, + default_designs: list[tuple[float, dict[str, Any]]], + project_id: UUID | None = None, +) -> None: + """임시 저장과 확정이 **함께 쓰는** 저장 본체. 트랜잭션은 호출한 쪽이 연다.""" + for chainage_m, design in default_designs: + await update_cross_section_design( + connection, + route_id=route_id, + chainage_m=chainage_m, + design=design, + project_id=project_id, + ) + if request and request.standard_cross_section: + await merge_longitudinal_section_options( + connection, + route_id=route_id, + options_patch={"standard_cross_section": request.standard_cross_section}, + ) + # 유토곡선 결과는 생성 옵션이 아니므로 data.options가 아니라 최상위 키에 둔다. + if request and request.mass_haul: + await merge_longitudinal_section_data( + connection, route_id=route_id, data_patch={"mass_haul": request.mass_haul} + ) + # 프론트 세션 보관값(암 경계선 오프셋 등)을 측점별 design에 병합. + if request and request.cross_patches: + for patch_item in request.cross_patches: + 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: + await merge_cross_section_design_patch( + connection, route_id=route_id, chainage_m=patch_item.chainage_m, patch=patch + ) + + +@router.post("/{project_id}/sections/{route_id}/save", response_model=SectionConfirmResponse) +async def save_sections( + project_id: UUID, + route_id: int, + request: SectionConfirmRequest | None = Body(default=None), +) -> SectionConfirmResponse | JSONResponse: + """편집 중인 종횡단을 **확정하지 않고** 영구저장소에만 남긴다(임시 저장). + + 저장 내용은 확정과 같지만 경로 상태·워크플로 단계를 건드리지 않는다. 미지정 측점을 + 기본값으로 채우지도 않는다 — 임시 저장은 **사용자가 실제로 손댄 것만** 남기는 게 맞다. + """ + pool = get_db_pool() + try: + async with pool.acquire() as connection: + if not await get_longitudinal_section(connection, project_id, route_id): + return JSONResponse( + status_code=404, + content={"status": "error", "message": "저장할 종횡단이 없습니다."}, + ) + await connection.begin() + try: + await _apply_section_edits(connection, route_id, request, []) + await connection.commit() + except Exception: + await connection.rollback() + raise + return SectionConfirmResponse( + project_id=str(project_id), route_id=route_id, confirmed=False + ) + except Exception: + logger.exception( + "B06 종횡단 임시 저장 실패: project_id=%s route_id=%s", project_id, route_id + ) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "종횡단 임시 저장 중 오류가 발생했습니다."}, + ) + + +@router.post("/{project_id}/sections/{route_id}/confirm", response_model=SectionConfirmResponse) +async def confirm_sections( + project_id: UUID, + route_id: int, + request: SectionConfirmRequest | None = Body(default=None), +) -> SectionConfirmResponse | JSONResponse: + """경로의 종횡단면을 확정(CONFIRMED)한다. + + 지반유형을 지정하지 않은 측점은 기본값(토사/좌절토)으로 자동 채운 뒤 확정한다. + 표준 횡단면 설정값이 함께 오면 longitudinal_sections.data.options에 저장한다. + """ + pool = get_db_pool() + try: + async with pool.acquire() as connection: + existing = await get_longitudinal_section(connection, project_id, route_id) + if not existing: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "확정할 종횡단이 없습니다."}, + ) + stored_path = await get_project_storage_relative_path(connection, project_id) + missing = await get_cross_sections_missing_design_chainages(connection, route_id) + + # 미지정 측점을 기본값으로 계산해 채운다 (계산 불가 측점은 조용히 건너뜀). + default_designs: list[tuple[float, dict[str, Any]]] = [] + if missing: + project_root = Path(resolve_stored_project_path(stored_path)) + default_designs = await asyncio.to_thread( + _compute_default_designs, + project_root, + str(existing["longitudinal_file_path"]), + missing, + request.standard_cross_section if request else None, + ) + + async with pool.acquire() as connection: + await connection.begin() + try: + await _apply_section_edits( + connection, route_id, request, default_designs, project_id + ) + await confirm_sections_for_route(connection, route_id) + async with connection.cursor() as cursor: + await complete_stage(cursor, str(project_id), 3) + await connection.commit() + except Exception: + await connection.rollback() + raise + + # 측구 방향(design.ditch_side) 변경을 B05 종단 정본 stations.uphill_side에 역반영한다(E-7). + # 파일 기반·비치명적: 실패해도 확정은 유지한다. + try: + async with pool.acquire() as connection: + designs = await get_cross_section_designs(connection, route_id) + overrides = [ + {"chainage_m": record["chainage_m"], "side": record["design"]["ditch_side"]} + for record in designs + if isinstance(record.get("design"), dict) + and record["design"].get("ditch_side") in ("left", "right") + ] + if overrides: + await asyncio.to_thread( + _merge_uphill_overrides_into_longitudinal, + Path(resolve_stored_project_path(stored_path)), + str(existing["longitudinal_file_path"]), + overrides, + ) + except Exception: + logger.exception( + "B06 측구 방향 B05 역반영 실패 (확정은 유지): project_id=%s route_id=%s", + project_id, + route_id, + ) + return SectionConfirmResponse(project_id=str(project_id), route_id=route_id) + except Exception: + logger.exception("B06 종횡단 확정 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "종횡단 확정 처리 중 오류가 발생했습니다."}, + ) diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance.ts index e055f3e0..63328776 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance.ts @@ -273,39 +273,43 @@ function ratioForChord(geometry: BlockGeometry, target: number): number { return high; } -/** 띠를 감싸는 다각형 — 아래 현 → 왼쪽 곡선 → 위 현 → 오른쪽 곡선. */ +/** + * 띠를 감싸는 다각형. + * + * 예전에는 「아래 현 → 왼쪽 곡선 → 위 현 → 오른쪽 곡선」을 이어 붙였는데, 곡선이 두 현 사이에서 + * 요동치면 그 조각이 띠 밖으로 삐져나가 **면이 스스로 겹쳤다**(2026-08-02 사용자 지적). + * + * 정의를 바꿨다. 띠는 「아래 현의 두 끝 사이에서, 아래 높이부터 **곡선과 위 높이 중 안쪽**까지」다. + * 아래 현을 밑변으로 깔고 그 위를 훑으며 곡선값을 두 높이 사이로 **자른 값**만 찍는다. + * 이러면 어떤 지형에서도 단순 다각형이라 스스로 겹칠 수가 없다. + */ function bandOutline( points: MassHaulPoint[], low: { from: number; to: number }, - high: { from: number; to: number }, lowLevel: number, highLevel: number, ): CurvePoint[] { - // 띠 테두리도 **곡선과 같은 포물선**을 따라야 한다 — 측점만 이으면 직선이 되어 - // 곡선과 벌어진다. 구간마다 잘게 쪼개 실제 곡선 위 점을 찍는다. - const between = (a: number, b: number): CurvePoint[] => { - const out: CurvePoint[] = []; - for (let index = 1; index < points.length; index += 1) { - const segment = segmentOf(points, index); - if (!segment) continue; - const low = Math.max(segment.x0, a); - const high = Math.min(segment.x0 + segment.span, b); - if (!(high > low)) continue; - for (let step = 1; step <= OUTLINE_STEPS; step += 1) { - const m = low + ((high - low) * step) / OUTLINE_STEPS; - out.push({ m, v: evaluate(segment, m - segment.x0) }); - } - } - return out; + const floor = Math.min(lowLevel, highLevel); + const ceil = Math.max(lowLevel, highLevel); + const outline: CurvePoint[] = [{ m: low.from, v: lowLevel }]; + const push = (m: number, v: number): void => { + const previous = outline[outline.length - 1]; + if (previous && Math.abs(previous.m - m) < 1e-9 && Math.abs(previous.v - v) < 1e-9) return; + outline.push({ m, v }); }; - return [ - { m: low.from, v: lowLevel }, - ...between(low.from, high.from), - { m: high.from, v: highLevel }, - { m: high.to, v: highLevel }, - ...between(high.to, low.to).reverse(), - { m: low.to, v: lowLevel }, - ]; + for (let index = 1; index < points.length; index += 1) { + const segment = segmentOf(points, index); + if (!segment) continue; + const start = Math.max(segment.x0, low.from); + const end = Math.min(segment.x0 + segment.span, low.to); + if (!(end > start)) continue; + for (let step = 0; step <= OUTLINE_STEPS; step += 1) { + const m = start + ((end - start) * step) / OUTLINE_STEPS; + push(m, Math.min(Math.max(evaluate(segment, m - segment.x0), floor), ceil)); + } + } + push(low.to, lowLevel); + return outline; } /** @@ -370,13 +374,7 @@ export function computeHaulPlan( haul_distance_m: haulChord.length, haul_from_m: haulChord.from, haul_to_m: haulChord.to, - outline: bandOutline( - points, - lowChord, - highChord, - geometry.levelAt(lower), - geometry.levelAt(upper), - ), + outline: bandOutline(points, lowChord, geometry.levelAt(lower), geometry.levelAt(upper)), ...apportion(cutMix(points, cutFrom, cutTo, result.conversion), bandVolume), }); upper = lower; diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts index 0cdf2c27..4657fe57 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts @@ -40,7 +40,6 @@ import { NOTCH, PAD_X, PAD_Y, - RESIDUAL_ARROW_PX, textWidth, TWO_LINE_ROOM_PX, } from "./B06_wf3_ProfileCross_UI_MassHaul_Balloon"; @@ -53,6 +52,23 @@ export { resetBalloonOffsets, } from "./B06_wf3_ProfileCross_UI_MassHaul_Balloon"; +/** + * 옮긴 balloon이 그래프 밖으로 나가지 않게 이동량을 자른다. 화면을 줄이면 상자가 좁아져 + * 예전에 저장해 둔 이동량이 밖을 가리킬 수 있으므로, 그릴 때마다 다시 자른다 + * (2026-08-02 사용자 지적). + */ +function offsetClamper( + home: { x: number; y: number }, + width: number, + height: number, + box: BalanceLayerBox, +): (dx: number, dy: number) => [number, number] { + return (dx, dy) => [ + Math.min(Math.max(home.x + dx, box.left + width / 2), box.right - width / 2) - home.x, + Math.min(Math.max(home.y + dy, box.top + height / 2), box.bottom - height / 2) - home.y, + ]; +} + /** 계단형 평형선(기선) — 칸 사이는 수직선으로 이어 계단을 만든다. */ function appendBalanceLine(group: SVGGElement, plan: HaulPlan, box: BalanceLayerBox): void { const steps = [...plan.steps].sort((a, b) => a.from_m - b.from_m); @@ -215,13 +231,15 @@ function appendBandBalloon( ); group.append(leader, balloon); + const clampOffset = offsetClamper(home, width, height, box); const saved = balloonOffsets.get(band.index); if (saved) { - balloon.setAttribute("transform", `translate(${saved[0]} ${saved[1]})`); - leader.setAttribute("x2", String(home.x + saved[0])); - leader.setAttribute("y2", String(home.y + saved[1])); + const [dx, dy] = clampOffset(saved[0], saved[1]); + balloon.setAttribute("transform", `translate(${dx} ${dy})`); + leader.setAttribute("x2", String(home.x + dx)); + leader.setAttribute("y2", String(home.y + dy)); } - attachBalloonDrag(balloon, leader, svg, band.index, home); + attachBalloonDrag(balloon, leader, svg, band.index, home, clampOffset); return balloon; } @@ -247,12 +265,11 @@ function appendResidual( ); const y1 = box.y(residual.level_from_m3); const y2 = box.y(residual.level_to_m3); - // 단차가 그래프를 가로지를 만큼 커도 화살표는 짧게 — 크기는 라벨의 수량이 말한다. + // 화살표는 **실제 단차 끝까지** 그린다 — 끝나는 높이가 새 평형선이라 그 자체가 정보다 + // (2026-08-02 사용자 지시). 짧게 자르면 어디까지 옮겨 갔는지 읽을 수 없다. + // 대신 수량 라벨을 balloon처럼 떼어 놓아 곡선을 가리지 않게 했다. const down = y2 > y1; - const tipY = Math.min( - Math.max(y1 + (down ? RESIDUAL_ARROW_PX : -RESIDUAL_ARROW_PX), box.top + 2), - box.bottom - 2, - ); + const tipY = Math.min(Math.max(y2, box.top + 2), box.bottom - 2); // 도면은 사토 balloon에 거리(L)가 아니라 **측점**(M.N)을 적는다 — 사토는 운반이 아니라 // 그 자리 처리라 운반거리를 매기지 않기 때문이다(2026-08-02 도면 분석). const at = (residual.from_m + residual.to_m) / 2; @@ -360,7 +377,15 @@ function appendResidual( ); group.append(leader, text); // 잔량 라벨도 끌어 옮길 수 있다. 띠 번호와 겹치지 않게 **음수 키**를 쓴다. - attachBalloonDrag(text, leader, svg, -residual.index, home); + const clampOffset = offsetClamper(home, width, height, box); + const saved = balloonOffsets.get(-residual.index); + if (saved) { + const [dx, dy] = clampOffset(saved[0], saved[1]); + text.setAttribute("transform", `translate(${dx} ${dy})`); + leader.setAttribute("x2", String(home.x + dx)); + leader.setAttribute("y2", String(home.y + dy)); + } + attachBalloonDrag(text, leader, svg, -residual.index, home, clampOffset); } /** diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balloon.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balloon.ts index 97a1c964..5fb9d37b 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balloon.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balloon.ts @@ -63,8 +63,6 @@ export const FULL_BALLOON_ROOM_PX = 165; export const TWO_LINE_ROOM_PX = 120; /** 이보다 얇은 띠에는 평균운반거리 선을 긋지 않는다(경계현과 붙어 한 줄로 보인다). */ export const MIN_BAND_THICKNESS_PX = 9; -/** 사토·토취 단차 화살표 길이(px). 단차가 아무리 커도 이만큼만 그린다 — 크기는 라벨이 말한다. */ -export const RESIDUAL_ARROW_PX = 22; /** * 사용자가 끌어 옮긴 balloon 위치(띠 번호 → [dx, dy]). @@ -265,7 +263,12 @@ export function findSlot( return candidate; } } - return { x: clampX(preferredX), y: preferredY }; + // 빈자리를 못 찾아도 **상자 안에는** 넣는다 — 안 그러면 라벨이 그래프 밖으로 나간다 + // (2026-08-02 사용자 지적). + return { + x: clampX(preferredX), + y: Math.min(Math.max(preferredY, box.top + height / 2), box.bottom - height / 2), + }; } /** @@ -279,6 +282,8 @@ export function attachBalloonDrag( svg: SVGSVGElement, key: number, home: { x: number; y: number }, + /** 옮긴 자리를 그릴 수 있는 범위 안으로 자르는 함수. 없으면 자르지 않는다. */ + clampOffset?: (dx: number, dy: number) => [number, number], ): void { let start: { x: number; y: number; dx: number; dy: number } | null = null; const scale = (): number => { @@ -286,6 +291,8 @@ export function attachBalloonDrag( const viewBox = svg.viewBox.baseVal?.width || rendered; return rendered > 0 && viewBox > 0 ? viewBox / rendered : 1; }; + const clampTo = (dx: number, dy: number): [number, number] => + clampOffset ? clampOffset(dx, dy) : [dx, dy]; const apply = (dx: number, dy: number): void => { balloon.setAttribute("transform", `translate(${dx} ${dy})`); leader.setAttribute("x2", String(home.x + dx)); @@ -304,8 +311,10 @@ export function attachBalloonDrag( balloon.addEventListener("pointermove", (event) => { if (!start) return; const factor = scale(); - const dx = start.dx + (event.clientX - start.x) * factor; - const dy = start.dy + (event.clientY - start.y) * factor; + const [dx, dy] = clampTo( + start.dx + (event.clientX - start.x) * factor, + start.dy + (event.clientY - start.y) * factor, + ); balloonOffsets.set(key, [dx, dy]); apply(dx, dy); }); diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index 50202be2..a033e779 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -19,6 +19,7 @@ import { import { computeCrossDesign, confirmSections, + saveSections, type CrossSectionPatch, fetchSectionContext, fetchSectionDetail, @@ -94,6 +95,15 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { onClick: () => void applyCrossHalfWidth(), }); recalcButton.disabled = true; + // 임시 저장 — 확정과 같은 내용을 남기되 페이지 이동이 없다(2026-08-02 사용자 지시). + // 자리는 재계산과 확정 사이(1행 3열). + const saveButton = createButton({ + label: L("B06_Profile_Btn_Save"), + variant: "ghost", + onClick: () => void saveCurrentSections(), + }); + saveButton.title = L("B06_Profile_Btn_Save_Tip"); + saveButton.disabled = true; const confirmButton = createButton({ label: L("B06_Profile_Btn_Confirm"), variant: "filled", @@ -102,7 +112,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { confirmButton.disabled = true; const actionRow = document.createElement("div"); actionRow.className = "b06-profile__actions"; - actionRow.append(recalcButton, confirmButton); + actionRow.append(recalcButton, saveButton, confirmButton); const leftForm = document.createElement("div"); leftForm.className = "b06-profile__form"; @@ -343,6 +353,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { const width = crossHalfWidth(); const stale = sectionDetail !== null && width !== undefined && width !== appliedHalfWidth; recalcButton.disabled = !stale; + // 임시 저장은 재계산이 밀려 있어도 눌릴 수 있어야 한다 — 지금까지 편집분을 잃지 않는 게 목적이다. + saveButton.disabled = sectionDetail === null; confirmButton.disabled = sectionDetail === null || stale; } @@ -383,39 +395,71 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { 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, + }), + ); + // 유토곡선은 화면 표시 내내 프론트 메모리에만 있다가 저장 시점에만 영구 저장된다. + const result = + sectionDetail && context?.earthwork_conversion + ? computeMassHaul( + sectionDetail.cross_sections, + context.earthwork_conversion, + context.natural_spoil_min_ground_slope ?? undefined, + ) + : null; + return { + crossPatches, + massHaul: result + ? massHaulPayload( + result, + computeHaulPlan(result, context?.haul_equipment_limits), + balloonOffsetsPayload(), + ) + : undefined, + }; + } + + /** 임시 저장 — 저장만 하고 페이지는 그대로 둔다. */ + async function saveCurrentSections(): Promise { + if (!projectId || currentRouteId === null) return; + showLoadingOverlay(); + try { + const edits = collectSectionEdits(); + await saveSections( + projectId, + currentRouteId, + standardPanel?.getValues(), + edits.crossPatches.length ? edits.crossPatches : undefined, + edits.massHaul, + ); + showToast(L("B06_Profile_Save_Success"), "success"); + } catch (error) { + const detail = error instanceof Error ? ` ${error.message}` : ""; + showToast(`${L("B06_Profile_Save_Failed")}${detail}`, "error"); + } finally { + hideLoadingOverlay(); + } + } + async function confirmCurrentSections(): Promise { if (!projectId || currentRouteId === null) return; showLoadingOverlay(); try { - // 세션 보관 중인 측점별 암 경계선 오프셋을 확정 시점에 DB로 병합한다. - const crossPatches: CrossSectionPatch[] = [...rockOffsets.entries()].map( - ([chainage, offset]) => ({ - chainage_m: Number(chainage), - rock_boundary_offset_m: offset, - }), - ); - // 유토곡선은 화면 표시 내내 프론트 메모리에만 있다가 확정 시점에만 영구 저장된다. - const massHaul = - sectionDetail && context?.earthwork_conversion - ? computeMassHaul( - sectionDetail.cross_sections, - context.earthwork_conversion, - context.natural_spoil_min_ground_slope ?? undefined, - ) - : null; + const edits = collectSectionEdits(); await confirmSections( projectId, currentRouteId, standardPanel?.getValues(), - crossPatches.length ? crossPatches : undefined, - massHaul - ? massHaulPayload( - massHaul, - computeHaulPlan(massHaul, context?.haul_equipment_limits), - // 확정 시점에 프론트 캐시의 balloon 위치를 영구저장소로 넘긴다. - balloonOffsetsPayload(), - ) - : undefined, + edits.crossPatches.length ? edits.crossPatches : undefined, + edits.massHaul, ); showToast(L("B06_Profile_Confirm_Success"), "success"); goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[4]); diff --git a/main.py b/main.py index 59c58bd9..64c6178d 100644 --- a/main.py +++ b/main.py @@ -37,6 +37,9 @@ from B04_wf1_Surface.B04_wf1_Surface_Router_Inflow import router as b04_inflow_r from B04_wf1_Surface.B04_wf1_Surface_Router_Watershed import router as b04_watershed_router from B05_wf2_Route.B05_wf2_Route_Router import router as b05_route_router from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Router import router as b06_section_router +from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Router_Confirm import ( + router as b06_section_confirm_router, +) from B07_wf4_DesignDetail.B07_wf4_DesignDetail_Router import router as b07_design_router from common_util.common_util_auth import require_company, verify_session from common_util.common_util_resource_monitor import sample_resources_loop @@ -354,6 +357,7 @@ app.include_router(b04_basins_router, dependencies=protected_with_company) app.include_router(tiles_router, dependencies=protected_with_company) app.include_router(b05_route_router, dependencies=protected_with_company) app.include_router(b06_section_router, dependencies=protected_with_company) +app.include_router(b06_section_confirm_router, dependencies=protected_with_company) app.include_router(b07_design_router, dependencies=protected_with_company) diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 6de57f7a..27f250a4 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -155,6 +155,14 @@ export const ui_locales_b2 = { B06_Profile_Smooth_On: ["사용", "On"], B06_Profile_Smooth_Off: ["미사용", "Off"], B06_Profile_Btn_Confirm: ["종·횡단 확정", "Confirm Sections"], + /* 임시 저장 — 확정과 같은 내용을 저장하되 경로 상태·워크플로 단계는 건드리지 않는다. */ + B06_Profile_Btn_Save: ["임시 저장", "Save draft"], + B06_Profile_Btn_Save_Tip: [ + "지금까지 편집한 내용을 확정하지 않고 저장합니다. 페이지는 그대로 있습니다.", + "Saves the current edits without confirming. You stay on this page.", + ], + B06_Profile_Save_Success: ["편집 내용을 저장했습니다.", "Draft saved."], + B06_Profile_Save_Failed: ["임시 저장에 실패했습니다.", "Failed to save the draft."], B06_Profile_Result_Title: ["종·횡단 생성 결과", "Section Result"], B06_Profile_Context_Failed: [ "경로 정보를 불러오지 못했습니다. 서버 상태를 확인하세요.",