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:
@@ -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;
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user