feat(B06): 횡단 반폭 개편·개별 반폭·재계산 통합 + 측구 방향 확정 동기화

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 19:48:00 +09:00
co-authored by Claude Fable 5
parent 4cb1f30f33
commit d93bd6e28d
13 changed files with 402 additions and 79 deletions
+15 -2
View File
@@ -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(
@@ -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:
@@ -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 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */
@@ -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(
@@ -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
@@ -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):
@@ -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;
}
@@ -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<void> {
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<void> {
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<void> {
/** 패널 [전체 반영](N-2-1): design 보유 전 측점을 패널 최신값으로 순차 재계산한다.
* handleDesignChange가 standardPanel.getValues()를 실어 보내므로 표준단면 수치만
* 갱신되고 측점별 버튼 선택값은 보존된다. 순차 await로 동시 API 호출 수를 제한한다. */
* 갱신되고 측점별 버튼 선택값은 보존된다. 순차 await로 동시 API 호출 수를 제한한다.
*
* 횡단 반폭 적용도 여기서 한다(2026-08-06 사용자 지시 — 재계산 버튼 폐지):
* 요청 반폭 ≤ 보유 샘플 폭 → 표시 범위만 바뀌므로 재렌더로 끝(재계산 없음).
* 요청 반폭 > 보유 샘플 폭 → B05부터 재생성해 영구 저장 후 상세를 다시 로드한다
* (기본 설계는 상세 조회가 자동으로 얹는다. 느려도 허용 — 2026-08-06 사용자 확정). */
async function applyPanelToAll(): Promise<void> {
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<void> {
},
};
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<string, number>();
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<string, number>;
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<void> {
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<void> {
}
}
async function applyCrossHalfWidth(): Promise<void> {
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<string, unknown> | undefined;
} {
const crossPatches: CrossSectionPatch[] = [...rockOffsets.entries()].map(
([chainage, offset]) => ({
chainage_m: Number(chainage),
rock_boundary_offset_m: offset,
}),
);
// 암 경계 오프셋 + 측점 개별 표시 반폭을 chainage 기준으로 합쳐 한 패치로 보낸다.
const patchByChainage = new Map<number, CrossSectionPatch>();
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<void> {
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<void> {
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<void> {
),
);
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();
@@ -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),
),
);
@@ -159,6 +159,8 @@ export function createStandardPanel(
projectId: string,
defaults: StandardCrossSection,
onApplyAll?: () => void | Promise<void>,
/** [전체 측점 반영] 버튼 **위**에 끼울 추가 컨트롤(횡단 반폭 입력, 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,
@@ -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;
+4 -1
View File
@@ -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"))
+3 -1
View File
@@ -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"],