Files
Aislo/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts
T
eomsangdonandClaude Opus 5 bc877b832a feat(B06): 유토곡선 운반 블록을 장비 경계현으로 띠 분할 + 도면 정합 5건
참고 도면의 토량배분도는 블록마다 수평선이 여러 개(A-A' 기선 / B-B' / C-C')이고
그 사이 띠마다 번호가 붙어 종단면 해칭 구간과 1:1로 대응한다. 하단 브래킷의 폭이
각 수평선의 길이와 같다. 즉 수평선 높이는 그 현의 길이가 장비 경계거리와 같아지는
높이이고, 세로축이 곧 토량이므로 두 수평선 사이 띠 두께가 그 장비의 운반토량이다.

블록 하나에 평균운반거리 하나 / 장비 하나이던 모델을 띠(HaulBand) 여럿으로 바꿨다.

경계 탐색이 단조가 아니라는 것을 무작위 검증이 잡았다. 가지치기로 블록을 하나로
묶어도 원래 폴리라인에 잔요철이 남아 높이를 올리는 도중 현이 잠깐 다시 길어진다.
순수 이분법은 엉뚱한 근으로 수렴했다(종무대 경계현 45m). 훑기 64칸 + 이분법 24회
2단계로 바꿔, 현이 상한을 넘는 가장 높은 자리 위를 경계로 잡는다.

- 장비 경계 dozer 50 -> 70m (사용자 확정, 정의처는 config 한 곳 유지)
- balloon 도형 = 운반수단: 종무대 육각 / 도쟈 원 / 덤프 사각, 사토는 언더바만
- 띠 면 클릭 시 balloon 강조, 다른 띠·빈 곳 클릭 시 해제
- 선타입: 기선 실선 / 경계현 파선 / 평균운반거리 점선 + 방향 화살촉
- 유토곡선 0선을 파선에서 실선으로 (파선은 경계현의 몫)
- 암 경계선 오프셋 상한 0 (양수면 경계선이 지표면 위로 떠 토사층이 음수가 된다)
- 종단도 구조물 라벨을 플롯 하단으로 옮기고 맨 마지막에 그려 가리지 않게
- 암 경계선 제어를 그래프 하단 행 가운데로

검증: 수동 4종 + 실측 26측점 + 무작위 100건에서 띠 합=Q, 띠 연속성, 경계현<=상한,
안분합, 보존 성립. 렌더 스모크 4개 크기 NaN 0건 + 도형·클릭 강조 확인.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 16:42:10 +09:00

512 lines
20 KiB
TypeScript

import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import {
fetchWorkflowState,
goToWorkflowStage,
WORKFLOW_STEP_ROUTES,
type WorkflowState,
} from "../A00_Common/b_workflow_nav";
import {
computeCrossDesign,
confirmSections,
type CrossSectionPatch,
fetchSectionContext,
fetchSectionDetail,
getSections,
regenerateSections,
type SectionContextResponse,
type SectionDetailResponse,
type StandardCrossSection,
} from "./B06_wf3_ProfileCross_Api_Fetch";
import {
type CrossDesignChange,
createSectionView,
type RockBoundaryControl,
} from "./B06_wf3_ProfileCross_UI_Section_View";
import { computeMassHaul, massHaulPayload } from "./B06_wf3_ProfileCross_UI_MassHaul";
import { computeHaulPlan } from "./B06_wf3_ProfileCross_UI_MassHaul_Balance";
import { designElevationAt } from "./B06_wf3_ProfileCross_UI_Section_Common";
import {
createStandardPanel,
type StandardPanelController,
} from "./B06_wf3_ProfileCross_UI_Standard_Panel";
import "./B06_wf3_ProfileCross_UI_Style.css";
import "./B06_wf3_ProfileCross_UI_Style_Cross.css";
import "./B06_wf3_ProfileCross_UI_Style_MassHaul.css";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
// B05 사이드패널 접기 컨테이너 템플릿 재사용(N-4-1): 제목 행 클릭 토글, 우측 ▾/▸ 캐럿.
function buildGroup(legend: string, collapsed = false): HTMLElement {
const group = document.createElement("section");
group.className = `b06-profile__group ui-collapsible${collapsed ? " is-collapsed" : ""}`;
const legendElement = document.createElement("h3");
legendElement.className = "b06-profile__group-legend ui-collapsible__title";
legendElement.textContent = legend;
group.append(legendElement);
return group;
}
export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
let currentRouteId: number | null = null;
let sectionDetail: SectionDetailResponse | null = null;
let stationInterval: number | undefined;
// 표준 횡단면 설정 패널 자리. context 로드 후 config 기본값으로 채운다.
const standardGroup = buildGroup(L("B06_Std_Title"));
const standardPanelSlot = document.createElement("div");
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";
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;
const confirmButton = createButton({
label: L("B06_Profile_Btn_Confirm"),
variant: "filled",
onClick: () => void confirmCurrentSections(),
});
confirmButton.disabled = true;
const actionRow = document.createElement("div");
actionRow.className = "b06-profile__actions";
actionRow.append(recalcButton, confirmButton);
const leftForm = document.createElement("div");
leftForm.className = "b06-profile__form";
leftForm.append(standardGroup, displayGroup, actionRow);
// 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님.
attachCollapsible(leftForm);
// 측점별 최신 요청 시퀀스 — 늦게 도착한 옛 응답을 폐기해 경합을 방지한다.
const designRequestSeq = new Map<number, number>();
/**
* 측점 설계 버튼 변경 처리: (1) 선택을 즉시 로컬 반영해 해당 카드만 리프레시(버튼 즉시 반응),
* (2) 서버에서 단면적을 계산·저장하고 최신 요청이면 그 카드만 다시 갱신한다. 전체 재렌더 없음.
* 설정 패널 편집값을 요청에 실어 요청값 → DB 저장 옵션 → config 기본값 우선순위를 지킨다.
*/
async function handleDesignChange(chainageM: number, change: CrossDesignChange): Promise<void> {
if (!projectId || currentRouteId === null || !sectionDetail) return;
const target = sectionDetail.cross_sections.find(
(section) => Math.abs(section.chainage_m - chainageM) < 0.01,
);
if (!target) return;
// (1) 즉시 로컬 반영: 선택 버튼만 갱신(숫자·설계선은 기존값 유지) → 해당 카드만 교체.
if (target.design) {
target.design = {
...target.design,
ground_type: change.ground_type,
section_mode: change.section_mode,
ditch_side: change.ditch_side ?? target.design.ditch_side,
ditch_type: change.ditch_type,
paved: change.paved,
two_stage_slope: change.two_stage_slope,
};
sectionView.refreshCard(chainageM);
}
// (2) 서버 계산 — 최신 요청만 반영. 암 경계 오프셋은 세션 우선값을 실어 2단계 무릎을 계산시킨다.
const seq = (designRequestSeq.get(chainageM) ?? 0) + 1;
designRequestSeq.set(chainageM, seq);
try {
const response = await computeCrossDesign(projectId, currentRouteId, {
chainage_m: chainageM,
...change,
rock_boundary_offset_m: rockBoundaryControl.offsetFor(target),
standard_cross_section: standardPanel?.getValues(),
});
if (designRequestSeq.get(chainageM) !== seq) return;
target.design = response.design;
sectionView.refreshCard(chainageM);
} catch (error) {
if (designRequestSeq.get(chainageM) !== seq) return;
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`${L("B06_Design_Failed")}${detail}`, "error");
}
}
/** 현재 design 값에서 재계산용 change를 복원한다(암 경계 오프셋 변경 시 재계산 트리거). */
function changeFromDesign(chainageM: number): CrossDesignChange | null {
const target = sectionDetail?.cross_sections.find(
(section) => Math.abs(section.chainage_m - chainageM) < 0.01,
);
const design = target?.design;
if (!design) return null;
return {
ground_type: design.ground_type,
section_mode: design.section_mode,
ditch_side: design.ditch_side ?? null,
ditch_type: design.ditch_type ?? "standard",
paved: design.paved,
two_stage_slope: design.two_stage_slope ?? true,
ditch_enabled: design.ditch_enabled ?? null,
};
}
/** 암 경계 오프셋 변경 후 암 지반이면 2단계 무릎·단면적을 서버 재계산한다. */
function recomputeIfRock(chainageM: number): void {
const change = changeFromDesign(chainageM);
if (change && change.ground_type !== "soil") void handleDesignChange(chainageM, change);
}
/**
* 로드 시 stale design을 최신 엔진·최신 종단 계획고로 자동 재계산한다(E-1 + N-6).
* 대상: (1) 2단계 경사 필드(`two_stage_slope`)가 없는 옛 암 측점, (2) B05에서 종단이
* 변경·확정돼 저장된 계산 기준 계획고(`design.design_elevation_m`)가 현재 계획선
* (`design_profiles`) 보간값과 어긋난 측점. 종단이 안 바뀐 측점은 0건이라 불필요한 API
* 호출이 없다. 측점별 버튼 선택값은 `changeFromDesign()` 경유로 보존한다.
*/
async function reconcileStaleDesigns(): Promise<void> {
if (!sectionDetail) return;
const profiles = sectionDetail.longitudinal.design_profiles;
const stale = sectionDetail.cross_sections.filter((section) => {
const design = section.design;
if (!design) return false;
if (design.geometry_preset === "rock" && design.two_stage_slope === undefined) return true;
const planZ = designElevationAt(profiles, section.chainage_m);
return planZ !== undefined && Math.abs(planZ - design.design_elevation_m) > 1e-3;
});
if (!stale.length) return;
showLoadingOverlay();
try {
for (const section of stale) {
const change = changeFromDesign(section.chainage_m);
if (change) await handleDesignChange(section.chainage_m, change);
}
} finally {
hideLoadingOverlay();
}
}
/** 패널 [전체 반영](N-2-1): design 보유 전 측점을 패널 최신값으로 순차 재계산한다.
* handleDesignChange가 standardPanel.getValues()를 실어 보내므로 표준단면 수치만
* 갱신되고 측점별 버튼 선택값은 보존된다. 순차 await로 동시 API 호출 수를 제한한다. */
async function applyPanelToAll(): Promise<void> {
if (!sectionDetail) return;
const targets = sectionDetail.cross_sections.filter((section) => section.design);
if (!targets.length) return;
showLoadingOverlay();
try {
for (const section of targets) {
const change = changeFromDesign(section.chainage_m);
if (change) await handleDesignChange(section.chainage_m, change);
}
showToast(L("B06_Std_ApplyAll_Success"), "success");
} finally {
hideLoadingOverlay();
}
}
/* ── 암 경계선 오프셋(측점별) 세션 저장소 ─────────────────────────────
* 서버 재계산 없이 프론트 세션(sessionStorage)에 보관하고, 종횡단 확정 시
* cross_patches로 DB(data.design.rock_boundary_offset_m)에 병합한다.
* 기본 오프셋·스텝은 context(config) 값으로 갱신된다. */
let rockBoundaryDefault = -0.5;
let rockBoundaryStep = 0.1;
/**
* 암 경계선 오프셋은 **0을 넘을 수 없다**(2026-08-02 사용자 지시). 오프셋은 지면선에서
* 아래로 파고든 깊이라, 양수가 되면 경계선이 지표면 위로 떠올라 토사층이 음수가 된다.
* DB에 옛 양수값이 남아 있어도 읽는 즉시 0으로 눌러 계산이 뒤집히지 않게 한다.
*/
const clampRockOffset = (value: number): number => Math.min(value, 0);
const rockOffsets = new Map<string, number>();
const rockKey = (chainageM: number): string => chainageM.toFixed(2);
const rockSessionKey = (): string | null =>
projectId && currentRouteId !== null ? `b06:rockb:${projectId}:${currentRouteId}` : null;
function loadRockOffsets(): void {
rockOffsets.clear();
const key = rockSessionKey();
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, offset]) => {
if (Number.isFinite(offset)) rockOffsets.set(chainage, offset);
});
} catch {
/* 손상된 세션 값은 무시 — 기본값으로 재시작. */
}
}
function persistRockOffsets(): void {
const key = rockSessionKey();
if (!key) return;
try {
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(rockOffsets)));
} catch {
/* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */
}
}
const rockBoundaryControl: RockBoundaryControl = {
get stepM() {
return rockBoundaryStep;
},
get defaultOffsetM() {
return rockBoundaryDefault;
},
offsetFor: (section) =>
clampRockOffset(
rockOffsets.get(rockKey(section.chainage_m)) ??
section.design?.rock_boundary_offset_m ??
rockBoundaryDefault,
),
adjust: (chainageM, deltaM) => {
const key = rockKey(chainageM);
const stored = sectionDetail?.cross_sections.find(
(section) => Math.abs(section.chainage_m - chainageM) < 0.01,
)?.design?.rock_boundary_offset_m;
const current = clampRockOffset(rockOffsets.get(key) ?? stored ?? rockBoundaryDefault);
rockOffsets.set(key, clampRockOffset(Math.round((current + deltaM) * 100) / 100));
persistRockOffsets();
sectionView.refreshCard(chainageM);
recomputeIfRock(chainageM); // 경계 이동 → 2단계 무릎·단면적 재계산
},
reset: (chainageM) => {
rockOffsets.set(rockKey(chainageM), rockBoundaryDefault);
persistRockOffsets();
sectionView.refreshCard(chainageM);
recomputeIfRock(chainageM);
},
};
const sectionView = createSectionView((chainageM, change) => {
void handleDesignChange(chainageM, change);
}, rockBoundaryControl);
// 메인 영역: 종·횡단 도면(sectionView) 또는 안내 메시지를 표시한다.
const mainArea = document.createElement("div");
mainArea.className = "b06-profile__main";
mainArea.append(sectionView.root);
function showSectionView(): void {
if (!mainArea.contains(sectionView.root)) mainArea.replaceChildren(sectionView.root);
}
function renderMessage(message: string): void {
const text = document.createElement("p");
text.className = "b06-profile__empty";
text.textContent = message;
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;
/** 반폭 미적용 상태에서는 [재계산]만 활성, 적용 완료 상태에서는 [확정]만 활성. */
function updateActionState(): void {
const width = crossHalfWidth();
const stale = sectionDetail !== null && width !== undefined && width !== appliedHalfWidth;
recalcButton.disabled = !stale;
confirmButton.disabled = sectionDetail === null || stale;
}
function renderSectionDetail(): void {
if (sectionDetail) {
showSectionView();
sectionView.render(
sectionDetail,
verticalExaggeration(),
crossHalfWidth(),
stationInterval,
context?.earthwork_conversion,
context?.haul_equipment_limits,
);
}
}
async function applyCrossHalfWidth(): Promise<void> {
const width = crossHalfWidth();
if (!projectId || currentRouteId === null || width === undefined) return;
showLoadingOverlay();
try {
sectionDetail = await regenerateSections(projectId, currentRouteId, width);
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);
async function confirmCurrentSections(): Promise<void> {
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)
: null;
await confirmSections(
projectId,
currentRouteId,
standardPanel?.getValues(),
crossPatches.length ? crossPatches : undefined,
massHaul
? massHaulPayload(massHaul, computeHaulPlan(massHaul, context?.haul_equipment_limits))
: undefined,
);
showToast(L("B06_Profile_Confirm_Success"), "success");
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[4]);
} catch (error) {
const detail = error instanceof Error ? error.message : L("B06_Profile_Confirm_Failed");
showToast(`${L("B06_Profile_Confirm_Failed")} ${detail}`, "error");
} finally {
hideLoadingOverlay();
}
}
let workflowState: WorkflowState | undefined;
let context: SectionContextResponse | null = null;
if (projectId) {
const [contextResult, workflowResult] = await Promise.allSettled([
fetchSectionContext(projectId),
fetchWorkflowState(projectId),
]);
if (contextResult.status === "fulfilled") context = contextResult.value;
else showToast(L("B06_Profile_Context_Failed"), "error");
if (workflowResult.status === "fulfilled") workflowState = workflowResult.value;
}
const layout = createWorkflowLayout({
title: L("B06_Profile_Title"),
steps: workflowSteps(),
activeStep: 3,
leftPanel: leftForm,
mainContent: mainArea,
stages: workflowState?.stages,
currentStage: workflowState?.current_stage,
routes: WORKFLOW_STEP_ROUTES,
onStepClick: (stepIndex) => {
if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
},
});
layout.root.classList.add("b06-profile-layout");
root.replaceChildren(layout.root);
if (!projectId) {
renderMessage(L("B06_Profile_Error_Project"));
return;
}
if (!context) {
renderMessage(L("B06_Profile_Context_Failed"));
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);
standardPanelSlot.append(standardPanel.root);
if (context.route_id === null) {
renderMessage(L("B06_Profile_Calculate_In_B05"));
return;
}
currentRouteId = context.route_id;
loadRockOffsets();
try {
const existing = await getSections(projectId, context.route_id);
if (!existing.longitudinal) {
renderMessage(L("B06_Profile_Calculate_In_B05"));
return;
}
sectionDetail = await fetchSectionDetail(projectId, context.route_id);
// 단일 소스(DB data.options) 우선, options 스냅샷이 없는 과거 데이터는 샘플 최대 offset으로 추정
const summaryData = existing.longitudinal.data as {
options?: {
cross_half_width_m?: number;
station_interval_m?: number;
standard_cross_section?: StandardCrossSection;
};
} | null;
const storedOptions = summaryData?.options;
// 확정 이력의 표준 횡단면 설정값 복원(진행 중 세션 편집값이 있으면 패널이 무시).
if (storedOptions?.standard_cross_section)
standardPanel?.applyStored(storedOptions.standard_cross_section);
const storedHalfWidth =
storedOptions?.cross_half_width_m && storedOptions.cross_half_width_m > 0
? storedOptions.cross_half_width_m
: Math.max(
0,
...sectionDetail.cross_sections.flatMap((section) =>
section.samples.map((sample) => Math.abs(sample.offset_m ?? 0)),
),
);
if (storedHalfWidth > 0) crossHalfWidthField.input.value = storedHalfWidth.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();
} catch (error) {
const detail = error instanceof Error ? ` ${error.message}` : "";
renderMessage(L("B06_Profile_Calculate_In_B05"));
showToast(`${L("B06_Profile_Detail_Failed")}${detail}`, "error");
}
}