feat(B04/B05): 재확정 재계산 체인(12) + 편집 버튼·유토곡선 UI 정리(13~15)

- 12: run_redesign_chain — B04 재확정 시 사용자 입력(stage 2 params) 유지한 채
  새 지표면 기준 B05 재계산·확정, 옛 측점별 설계·표준단면 설정을 chainage 매칭
  이월 후 B06 확정. 경로 없으면 신규 자동 체인 폴백. B04 confirm에 백그라운드 결선
- 13: 구간 이동 글리프 ⇧⇩ → 속 찬 ⬆︎⬇︎(21×17·굵게), 규칙·비정규 측점 버튼
  최소 간격 20px 캐스케이드 배치로 근접 측점 ▲▼ 겹침 해소
- 14: 유토곡선 손잡이를 표준 삼각형 손잡이(64×24, 바닥 중앙)로 통일, 캡션 제거,
  '유토곡선 펼치기/접기' 툴팁
- 15: 유토곡선 스크롤러 세로 휠 → 가로 이동(종단 그래프와 동기)

Playwright 검증: 버튼 27개 최소 간격 20px, 글리프 ⬆︎ 21×17, 표준 손잡이 64×24,
휠 스크롤 0→400 종단 동기, 콘솔 오류 0. typecheck·ruff 통과.

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