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: [ "경로 정보를 불러오지 못했습니다. 서버 상태를 확인하세요.",