649 lines
30 KiB
TypeScript
649 lines
30 KiB
TypeScript
import { createDesignSync } from "./B06_Section_UI_Page_Design_Sync";
|
|
import { createGradeEdit } from "./B06_Section_UI_Page_Grade_Edit";
|
|
import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
|
|
import { leaveForDashboard } from "../A00_Common/b_missing_data_guard";
|
|
import { readByKey, stateKey, writeByKey } from "../A00_Common/b_page_state";
|
|
import { navigateTo } from "../A00_Common/router";
|
|
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
|
import { mountMissingStationNotice } from "./B06_Section_UI_Missing_Stations";
|
|
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
|
|
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 {
|
|
fetchSectionContext,
|
|
getSections,
|
|
type SectionContextResponse,
|
|
type SectionDetailResponse,
|
|
type StandardCrossSection,
|
|
} from "./B06_Section_Api_Fetch";
|
|
import { createStationControls } from "./B06_Section_UI_Page_Station_Controls";
|
|
import {
|
|
confirmCurrentSections,
|
|
createCutSlopeStore,
|
|
createRockBoundaryStore,
|
|
saveCurrentSections,
|
|
type SectionPersistContext,
|
|
} from "./B06_Section_UI_Page_Persist";
|
|
import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit";
|
|
import {
|
|
readStructurePick,
|
|
writeStructurePick,
|
|
} from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
|
|
import { applyStructurePick } from "./B06_Section_UI_Page_Structure_Pick";
|
|
import { createSectionView } from "./B06_Section_UI_Section_View";
|
|
import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const";
|
|
import {
|
|
applyPipeOptionsToCache,
|
|
type PipeOptionsContext,
|
|
} from "./B06_Section_UI_Page_Pipe_Options";
|
|
import {
|
|
createStandardPanel,
|
|
effectiveStandardCross,
|
|
rememberRockBoundaryDefault,
|
|
type StandardPanelController,
|
|
} from "./B06_Section_UI_Standard_Panel";
|
|
import {
|
|
createB06StructuresPanel,
|
|
isWallSelecting,
|
|
wireStructureSelection,
|
|
} from "./B06_Section_UI_Page_Structures_Panel";
|
|
import "./B06_Section_UI_Style.css";
|
|
import "./B06_Section_UI_Style_Cross.css";
|
|
import "./B06_Section_UI_Style_Cross_Controls.css";
|
|
import "./B06_Section_UI_Style_Cross_Areas.css";
|
|
import { loadSectionDetail } from "./B06_Section_Section_Store";
|
|
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
|
|
import { buildGroup, createSampleWidener, L } from "./B06_Section_UI_Page_Common";
|
|
import "@util/common_util_mass_haul.css";
|
|
|
|
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 기본값으로 채운다.
|
|
// 표준 횡단면 설정은 기본 접힘(2026-08-23 사용자 지시) — 자주 여는 칸이 아니다.
|
|
const standardGroup = buildGroup(L("B06_Std_Title"), true);
|
|
const standardPanelSlot = document.createElement("div");
|
|
standardGroup.append(standardPanelSlot);
|
|
let standardPanel: StandardPanelController | null = null;
|
|
|
|
// 높이 배율 옵션은 폐지 — 항상 1배율(2026-08-06 사용자 지시).
|
|
// 횡단 반폭은 **자체 컨테이너 + 자체 [보기 반폭 적용] 버튼**이다(2026-08-23 사용자 지시)
|
|
// — 표준 횡단면 설정의 [전체 측점 반영]에 물려 있어 반폭만 바꿔도 전 측점 설계
|
|
// 재계산을 기다려야 했다. 요청 반폭이 보유 샘플 폭(기본 20m, config
|
|
// SECTION_CROSS_HALF_WIDTH_M) 이하면 표시만 바꾸므로 즉시 끝나고, 넘을 때만 재생성한다.
|
|
const crossHalfWidthField = createInputField({
|
|
label: L("B05_Route_Field_CrossHalfWidth"),
|
|
type: "number",
|
|
});
|
|
crossHalfWidthField.input.min = "0.1";
|
|
crossHalfWidthField.input.step = "0.1";
|
|
const applyWidthButton = createButton({
|
|
label: L("B06_View_Apply"),
|
|
variant: "filled",
|
|
onClick: () => void applyCrossHalfWidth(),
|
|
});
|
|
applyWidthButton.title = L("B06_View_Apply_Tip");
|
|
const viewGroup = buildGroup(L("B06_View_Title"));
|
|
// 반폭 입력 + [적용]은 한 줄(2026-08-23 사용자 지시).
|
|
const viewRow = document.createElement("div");
|
|
viewRow.className = "b06-profile__field-row";
|
|
viewRow.append(crossHalfWidthField.root, applyWidthButton);
|
|
viewGroup.append(viewRow);
|
|
/** 횡단면도 **표시** 반폭 기본값(m) — 2026-08-19 사용자 지시로 20 → 12. 계산·보유
|
|
* 샘플 폭은 백엔드 기본(SECTION_CROSS_HALF_WIDTH_M = 20m) 그대로라 12는 표시
|
|
* 범위만 좁힌다(12 ≤ 20이므로 재계산 없음). 계산 범위와 혼동 금지. */
|
|
const DISPLAY_HALF_WIDTH_DEFAULT_M = 12;
|
|
|
|
// 하단 액션 [종단 이동][임시저장][확정] — 종단 이동은 저장 없이 B05로 페이지 이동만,
|
|
// 확정은 임시저장과 같은 내용 저장 후 stage 2·3을 닫고 B07 상세설계로 넘어간다
|
|
// (2026-08-08 워크플로우 재정의).
|
|
const goProfileButton = createButton({
|
|
label: L("B06_Profile_Btn_GoProfile"),
|
|
variant: "ghost",
|
|
onClick: () => navigateTo(ROUTES.B05_PROFILE),
|
|
});
|
|
const saveButton = createButton({
|
|
label: L("B06_Profile_Btn_Save"),
|
|
variant: "ghost",
|
|
onClick: () => void saveCurrentSections(persistContext),
|
|
});
|
|
saveButton.title = L("B06_Profile_Btn_Save_Tip");
|
|
saveButton.disabled = true;
|
|
const confirmButton = createButton({
|
|
label: L("B06_Profile_Btn_Confirm"),
|
|
variant: "filled",
|
|
onClick: () => void confirmCurrentSections(persistContext),
|
|
});
|
|
confirmButton.disabled = true;
|
|
const actionRow = document.createElement("div");
|
|
actionRow.className = "b06-profile__actions";
|
|
actionRow.append(goProfileButton, saveButton, confirmButton);
|
|
|
|
/** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤 — 가장 가까운 카드로 간다. */
|
|
const focusStationAt = (chainageM: number): void => {
|
|
const sections = sectionDetail?.cross_sections;
|
|
if (!sections?.length) return;
|
|
const nearest = sections.reduce((best, entry) =>
|
|
Math.abs(entry.chainage_m - chainageM) < Math.abs(best.chainage_m - chainageM) ? entry : best,
|
|
);
|
|
sectionView.focusStation(nearest.station_id);
|
|
};
|
|
|
|
// 「구조물 배치」 — B05와 같은 컨테이너·하단 목록 템플릿(2026-08-29 일원화).
|
|
// 하단 고정 dock 도 B05와 같은 구조: [구조물 목록][구분선][액션 버튼 행].
|
|
let structureMarksSink:
|
|
((structures: StructureInstance[], types: StructureType[]) => void) | null = null;
|
|
const structuresPanel = createB06StructuresPanel({
|
|
projectId,
|
|
// 횡단도·3D 넘김값에서 고른 것이 폼에 실릴 때 좌측 패널을 펼친다(2026-09-04 사용자).
|
|
reveal: () => layout.setOptionsOpen(true),
|
|
// 종단 알약 레인에 같은 목록을 넘긴다 — B05 와 같은 표기(2026-09-07 사용자 지시 4).
|
|
// 뷰는 이 패널보다 **뒤에** 만들어지므로 그때 채워지는 참조를 통해 부른다.
|
|
onMarks: (structures, types) => {
|
|
structureMarksSink?.(structures, types);
|
|
syncBermSpans(structures);
|
|
},
|
|
// 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 캐시를 버리고 다시
|
|
// 받아 그려야 면적·유토곡선이 따라온다(2026-09-06 사용자 확정).
|
|
onStructuresChanged: () => void refreshDetailForStructures(),
|
|
detail: () => sectionDetail,
|
|
// 폼 기본 높이 = 지금 도면에 그려진 순수 높이(조정창이 보여주던 값과 같은 계산).
|
|
wallHeight: (chainageM, role) => {
|
|
const owner = sectionDetail?.cross_sections.find(
|
|
(section) => Math.abs(section.chainage_m - chainageM) < 0.51,
|
|
);
|
|
const culvert = owner?.culvert;
|
|
if (!owner || !culvert) return null;
|
|
const spec = role === "outlet" ? culvert.outlet : culvert.inlet;
|
|
const adjust = stationControls.revetOffset.adjustFor(owner, role);
|
|
// 조작(조정창 높이)이 있을 때만 — 없으면 그려진 높이는 관경 기준 제안이라 옵션에 안 적음
|
|
// (2026-09-14 브레인 판정 ④ 「기본값을 몰래 확정으로 안 바꿈」).
|
|
if (adjust?.h == null) return null;
|
|
return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m)
|
|
.pureHeight;
|
|
},
|
|
// 폼 형태 = 지금 도면이 쓰는 형태. 조작(조정값 m)이 있으면 그것이 정본이라
|
|
// 저장된 옵션보다 앞선다 — 안 맞추면 폼은 저장값, 그림은 조작값이 되어
|
|
// "유출구 형태를 바꿔도 안 바뀐다"로 보인다(2026-08-30 사용자 보고).
|
|
wallForm: (chainageM, role) => {
|
|
const owner = sectionDetail?.cross_sections.find(
|
|
(section) => Math.abs(section.chainage_m - chainageM) < 0.51,
|
|
);
|
|
const culvert = owner?.culvert;
|
|
if (!owner || !culvert) return null;
|
|
const spec = role === "outlet" ? culvert.outlet : culvert.inlet;
|
|
const adjust = stationControls.revetOffset.adjustFor(owner, role);
|
|
if (!adjust?.m) return null; // 조작한 형태만 — 기본 형태는 제안(판정 ④)
|
|
return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m).form;
|
|
},
|
|
// 좌·우 이름표 — +offset이 좌측이고 유입 벽은 오르막(계류) 쪽에 선다.
|
|
wallSideLabels: (chainageM) => {
|
|
const owner = sectionDetail?.cross_sections.find(
|
|
(section) => Math.abs(section.chainage_m - chainageM) < 0.51,
|
|
);
|
|
if (!owner) return null;
|
|
const inletLeft = (owner.uphill_side ?? "left") === "left";
|
|
return inletLeft ? { inlet: "좌", outlet: "우" } : { inlet: "우", outlet: "좌" };
|
|
},
|
|
stationInterval: () => stationInterval ?? 20,
|
|
focusChainage: focusStationAt,
|
|
queuePipeOptions: (chainageM, patch) => stationControls.queueCulvertOptions(chainageM, patch),
|
|
applyPipeOptions: (chainageM, patch) =>
|
|
applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch),
|
|
});
|
|
|
|
const dockDivider = document.createElement("hr");
|
|
dockDivider.className = "b05-structure__divider";
|
|
const actionDock = document.createElement("div");
|
|
actionDock.className = "b05-route__dock ui-sidebar-actions";
|
|
actionDock.append(structuresPanel.listRoot, dockDivider, actionRow);
|
|
|
|
const leftForm = document.createElement("div");
|
|
leftForm.className = "b06-profile__form";
|
|
// 순서: 구조물 배치(최상단 — 2026-08-29 사용자 지시) → 횡단 보기 설정 → 표준.
|
|
leftForm.append(structuresPanel.root, viewGroup, standardGroup, actionDock);
|
|
// 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님.
|
|
attachCollapsible(leftForm);
|
|
|
|
// 설계 선택 반영·재계산·소단 동기화는 따로 뗀 모듈이 맡는다(2026-09-13 분리).
|
|
const {
|
|
handleDesignChange,
|
|
recomputeIfRock,
|
|
reconcileStaleDesigns,
|
|
syncBermSpans,
|
|
applyPanelToAll,
|
|
} = createDesignSync({
|
|
projectId,
|
|
routeId: () => currentRouteId,
|
|
detail: () => sectionDetail,
|
|
view: () => sectionView,
|
|
});
|
|
|
|
/** 구조물(C군 벽)이 바뀌면 횡단 제원이 달라진다 — 초안을 얹고 다시 그린다. */
|
|
async function refreshDetailForStructures(): Promise<void> {
|
|
if (!projectId || currentRouteId === null) return;
|
|
try {
|
|
if (projectId && currentRouteId !== null) {
|
|
sectionDetail = await loadSectionDetail(projectId, currentRouteId);
|
|
}
|
|
// 면적을 **먼저** 다시 얹는다 — 유토곡선은 카드보다 앞서 계산되므로, 카드 렌더가
|
|
// 고치는 것만으로는 곡선이 옛 면적으로 남는다(2026-09-06 실측: 카드는 바뀌는데
|
|
// 최종 누가토량이 그대로였음).
|
|
if (sectionDetail) {
|
|
applyStructureAreaRows(
|
|
sectionDetail.cross_sections,
|
|
structureAreaRows(sectionDetail.cross_sections),
|
|
);
|
|
}
|
|
renderSectionDetail();
|
|
} catch (error) {
|
|
const detail = error instanceof Error ? ` ${error.message}` : "";
|
|
showToast(`구조물을 횡단에 반영하지 못했습니다.${detail}`, "error");
|
|
}
|
|
}
|
|
|
|
const ensureSampledWidth = createSampleWidener({
|
|
target: () =>
|
|
projectId && currentRouteId !== null ? { projectId, routeId: currentRouteId } : null,
|
|
sampledHalfWidth,
|
|
applyDetail: (fresh) => {
|
|
sectionDetail = fresh;
|
|
renderSectionDetail();
|
|
},
|
|
});
|
|
|
|
/**
|
|
* [보기 반폭 적용] — 표시 반폭만 반영한다(2026-08-23 사용자 지시로 분리).
|
|
* 보유 샘플(기본 20m) 안이면 재계산 없이 즉시, 넘을 때만 재생성이 돈다.
|
|
* 측점 개별 반폭은 전역값으로 초기화한다 — 남아 있으면 전역값이 무시된다.
|
|
*/
|
|
async function applyCrossHalfWidth(): Promise<void> {
|
|
if (!sectionDetail) return;
|
|
// 교차점이 샘플 밖인 측점이 있으면 그 폭까지 넓힌다(카드가 자동 줌아웃할 지반 확보).
|
|
const requested = crossHalfWidth();
|
|
const needed = maxToeFitHalfWidth(sectionDetail.cross_sections);
|
|
const target = Math.max(requested ?? 0, needed);
|
|
if (target > 0 && !(await ensureSampledWidth(target))) return;
|
|
if (!sectionDetail) return;
|
|
stationControls.applyGlobalWidth(
|
|
requested,
|
|
sectionDetail.cross_sections.map((section) => section.chainage_m),
|
|
);
|
|
persistDisplayHalfWidth();
|
|
renderSectionDetail();
|
|
showToast(L("B06_View_Apply_Success"), "success");
|
|
}
|
|
|
|
// 암 경계선 오프셋(측점별) 세션 저장소는 저장 흐름 모듈이 맡는다(2026-09-02 분리).
|
|
const rockStore = createRockBoundaryStore({
|
|
sessionKey: () => stateKey("rockb", projectId, currentRouteId),
|
|
detail: () => sectionDetail,
|
|
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
|
|
recompute: (chainageM) => recomputeIfRock(chainageM),
|
|
});
|
|
const rockOffsets = rockStore.offsets;
|
|
const rockBoundaryControl = rockStore.control;
|
|
|
|
// 측점별 암 절토 경사(2026-09-07 사용자 지시) — 암 경계선과 같은 꼴의 세션 저장소다.
|
|
// 전체를 바꾸는 자리는 좌측 [표준 횡단면 설정]이고, 여기 값은 그 측점 하나만 덮는다.
|
|
const cutSlopeStore = createCutSlopeStore({
|
|
sessionKey: () => stateKey("cutslope", projectId, currentRouteId),
|
|
standard: () => (projectId ? effectiveStandardCross(projectId) : null),
|
|
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
|
|
recompute: (chainageM) => recomputeIfRock(chainageM),
|
|
});
|
|
const cutSlopeRatios = cutSlopeStore.ratios;
|
|
|
|
const stationControls = createStationControls({
|
|
// 키는 등록표(`b_page_state`)가 만든다 — 이름만 넘기면 통·범위·옛 키 이관이 따라온다.
|
|
// 형변환을 두지 않는다: 등록표에 없는 이름을 쓰면 **컴파일에서** 걸린다
|
|
// (2026-09-06 `extraspan` 누락으로 B06 이 안 뜬 뒤 막음).
|
|
sessionKey: (kind) => stateKey(kind, projectId, currentRouteId),
|
|
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
|
|
detail: () => sectionDetail,
|
|
crossHalfWidth,
|
|
sampledHalfWidth,
|
|
ensureSampledWidth,
|
|
projectId: () => projectId,
|
|
onSaveError: (message) => showToast(`배수관 구간값 저장 실패 — ${message}`, "error"),
|
|
});
|
|
const { stationWidth: stationWidthControl, revetOffset: revetOffsetControl } = stationControls;
|
|
const { widths: stationWidths, inletStructures, basinAdjustments } = stationControls;
|
|
|
|
const sectionView = createSectionView(
|
|
(chainageM, change) => {
|
|
void handleDesignChange(chainageM, change);
|
|
},
|
|
rockBoundaryControl,
|
|
stationWidthControl,
|
|
revetOffsetControl,
|
|
stationControls.inletStructure,
|
|
stationControls.extraWalls,
|
|
stationControls.structureSpan,
|
|
stationControls.revetLink,
|
|
stationControls.ford,
|
|
stationControls.box,
|
|
cutSlopeStore.control,
|
|
);
|
|
// 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일).
|
|
structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types);
|
|
// 계획선 편집(▲/▼) 제공자는 따로 뗀 모듈이 맡는다(2026-09-13 분리).
|
|
const gradeEditFor = createGradeEdit({
|
|
routeId: () => currentRouteId,
|
|
detail: () => sectionDetail,
|
|
view: () => sectionView,
|
|
reconcile: () => reconcileStaleDesigns({ force: true }),
|
|
});
|
|
sectionView.setGradeEdit(gradeEditFor);
|
|
|
|
// 종단 그래프 우클릭 — B05 와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자: B05·B06 은 한
|
|
// 페이지라 같은 자리에서 되어야 한다). 어느 길로 들어와도 좌측 「구조물 배치」와
|
|
// 같은 함수를 타므로 목록·폼·알약이 함께 선다.
|
|
sectionView.setStructureEdit({
|
|
addPipe: (chainageM) => structuresPanel.addPipeAt(chainageM),
|
|
removePipe: (chainageM) => structuresPanel.removePipeAt(chainageM),
|
|
addStructureType: (chainageM, typeId) => structuresPanel.addStructureAt(chainageM, typeId),
|
|
removeStructure: (structureId) => {
|
|
structuresPanel.removeStructureById(structureId);
|
|
},
|
|
movePipe: (fromChainageM, toChainageM) =>
|
|
structuresPanel.movePipeTo(fromChainageM, toChainageM),
|
|
moveStructure: (structureId, toChainageM) => {
|
|
structuresPanel.moveStructureTo(structureId, toChainageM);
|
|
},
|
|
});
|
|
// 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리).
|
|
const pipeOptionsContext: PipeOptionsContext = {
|
|
detail: () => sectionDetail,
|
|
ford: stationControls.ford,
|
|
box: stationControls.box,
|
|
revetOffset: stationControls.revetOffset,
|
|
overrideOptions: (chainageM, values) => structuresPanel.overrideOptions(chainageM, values),
|
|
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
|
|
};
|
|
// 횡단도 벽·구체 선택 → 좌측 「구조물 배치」 폼에 그 시설 로드(2026-08-29 일원화).
|
|
const structureSelection = wireStructureSelection(
|
|
stationControls,
|
|
() => sectionDetail,
|
|
structuresPanel,
|
|
// 횡단도에서 고른 부재도 같은 세션 칸에 남긴다(2026-09-04 — 두 화면이 한 페이지처럼).
|
|
(chainageM, key) => writeStructurePick(projectId, chainageM, key),
|
|
);
|
|
// 횡단도(카드) 자체를 골라도 그 측점 구조물 정보를 폼에 올린다(2026-08-29 사용자).
|
|
// 연동으로 옆에서 넘어온 카드는 **소유 측점** 시설을 보여 준다.
|
|
sectionView.setStationSelectListener((stationId) => {
|
|
const target = stationId
|
|
? sectionDetail?.cross_sections.find((section) => section.station_id === stationId)
|
|
: null;
|
|
if (!target) {
|
|
structuresPanel.showAtChainage(null);
|
|
writeStructurePick(projectId, null); // 카드 해제 — 세션 선택도 비운다.
|
|
return;
|
|
}
|
|
const owner = stationControls.structureSpan.ownerOf(target);
|
|
structuresPanel.showAtChainage(owner?.chainage_m ?? target.chainage_m);
|
|
// 카드만 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다.
|
|
if (!isWallSelecting()) writeStructurePick(projectId, owner?.chainage_m ?? target.chainage_m);
|
|
structureSelection.syncInletStructure();
|
|
// 카드만 고른 경우엔 구조물(벽·구체) 선택을 비운다 — 조정창이 저절로 뜨면 안 된다
|
|
// (2026-08-29 사용자). 벽을 찍어 카드가 딸려 선택된 경우는 건드리지 않는다.
|
|
if (!isWallSelecting() && stationControls.revetOffset.selectedFor(target)) {
|
|
stationControls.revetOffset.select(target.chainage_m, null);
|
|
sectionView.refreshCard(target.chainage_m);
|
|
}
|
|
});
|
|
|
|
// 메인 영역: 종·횡단 도면(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 crossHalfWidth(): number | undefined {
|
|
const parsed = Number(crossHalfWidthField.input.value);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 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)),
|
|
),
|
|
);
|
|
}
|
|
|
|
/** 표시 반폭 세션 키 — 페이지를 떠났다 와도 조절값이 유지되게 한다. */
|
|
const displaySessionKey = (): string | null =>
|
|
stateKey("cross-display", projectId, currentRouteId);
|
|
|
|
function persistDisplayHalfWidth(): void {
|
|
const key = displaySessionKey();
|
|
const width = crossHalfWidth();
|
|
if (!key || width === undefined) return;
|
|
try {
|
|
writeByKey(key, String(width));
|
|
} catch {
|
|
/* 무시 */
|
|
}
|
|
}
|
|
|
|
function updateActionState(): void {
|
|
saveButton.disabled = sectionDetail === null;
|
|
confirmButton.disabled = sectionDetail === null;
|
|
}
|
|
|
|
/** 세션에 남은 선택을 이어받는다(2026-09-04 — 두 화면이 한 페이지처럼).
|
|
* **넘김값이 바뀌었을 때만** 적용한다 — 재렌더마다 다시 돌면 사용자가 옮긴 스크롤이
|
|
* 튀고, 진입당 한 번으로 막으면 자료가 늦게 온 경우 영영 안 열린다(2026-09-06). */
|
|
let appliedPick: string | null = null;
|
|
function restoreStructurePick(): void {
|
|
if (!sectionDetail) return;
|
|
const handoff = readStructurePick(projectId);
|
|
const signature = handoff ? `${handoff.at}|${handoff.key ?? ""}` : null;
|
|
if (signature === null || signature === appliedPick) return;
|
|
appliedPick = signature;
|
|
applyStructurePick(handoff, sectionDetail, {
|
|
focusStation: (stationId) => sectionView.focusStation(stationId),
|
|
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
|
|
revetSelect: (chainageM, key) => stationControls.revetOffset.select(chainageM, key),
|
|
fordSelect: (chainageM, role) => stationControls.ford.select(chainageM, role),
|
|
boxSelect: (chainageM, role) => stationControls.box.select(chainageM, role),
|
|
});
|
|
}
|
|
|
|
function renderSectionDetail(): void {
|
|
if (sectionDetail) {
|
|
showSectionView();
|
|
// 높이 배율은 항상 1(2026-08-06 사용자 지시 — 옵션 폐지).
|
|
sectionView.render(
|
|
sectionDetail,
|
|
1,
|
|
crossHalfWidth(),
|
|
stationInterval,
|
|
context?.earthwork_conversion,
|
|
context?.haul_equipment_limits,
|
|
`${projectId ?? "-"}:${currentRouteId ?? "-"}`,
|
|
context?.natural_spoil_min_ground_slope ?? undefined,
|
|
);
|
|
restoreStructurePick();
|
|
}
|
|
}
|
|
|
|
// [저장]·[확정]과 편집분 수집은 저장 흐름 모듈에 있다(2026-09-02 분리).
|
|
const persistContext: SectionPersistContext = {
|
|
projectId,
|
|
routeId: () => currentRouteId,
|
|
detail: () => sectionDetail,
|
|
context: () => context,
|
|
standardValues: () => standardPanel?.getValues(),
|
|
flushCulvertOptions: () => stationControls.flushCulvertOptions(),
|
|
// 저장 직전 전 측점 재계산 — 표준단면·계획선을 고친 뒤 안 만진 측점이 옛 면적으로
|
|
// 실려 나가던 자리(2026-09-09 실측).
|
|
reconcileDesigns: () => reconcileStaleDesigns({ force: true }),
|
|
patchSources: () => ({
|
|
rockOffsets,
|
|
cutSlopeRatios,
|
|
stationWidths,
|
|
inletStructures,
|
|
basinAdjustments,
|
|
revetAdjusts: stationControls.revetAdjustsByChainage(),
|
|
extraCounts: stationControls.extraCountsByChainage(),
|
|
extraSpans: stationControls.extraSpansByChainage(),
|
|
fordAdjusts: stationControls.fordAdjustsByChainage(),
|
|
boxAdjusts: stationControls.boxAdjustsByChainage(),
|
|
linkFlags: stationControls.linkFlagsByChainage(),
|
|
}),
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
// 표시 기본 12m — 계산 기본(context.defaults.cross_half_width_m = 20m)과 다르다
|
|
// (2026-08-19 사용자 지시). 사용자 확정 이력·세션값이 있으면 아래에서 덮는다.
|
|
crossHalfWidthField.input.value = String(DISPLAY_HALF_WIDTH_DEFAULT_M);
|
|
stationInterval = context.defaults.station_interval_m;
|
|
rockStore.setDefaults(context.rock_boundary_default_offset_m, context.rock_boundary_step_m);
|
|
|
|
// 표준 횡단면 설정 패널 장착(세션값 우선, 없으면 config 기본값).
|
|
// 횡단 반폭 입력은 [전체 측점 반영] 버튼 위로 들어간다(2026-08-06 사용자 지시).
|
|
standardPanel = createStandardPanel(projectId, context.standard_cross_section, applyPanelToAll);
|
|
// 브라우저 횡단 계산이 옛 암 측점을 서버와 같은 기본값으로 다시 계산하게 기억해 둔다.
|
|
rememberRockBoundaryDefault(projectId, context.rock_boundary_default_offset_m);
|
|
standardPanelSlot.append(standardPanel.root);
|
|
|
|
if (context.route_id === null) {
|
|
// 자료가 통째로 없으면(새 자료가 올라와 옛 결과가 지워진 경우) 대시보드로 돌려보낸다.
|
|
leaveForDashboard();
|
|
return;
|
|
}
|
|
|
|
currentRouteId = context.route_id;
|
|
rockStore.load();
|
|
cutSlopeStore.load();
|
|
stationControls.load();
|
|
// 구조물 배치 데이터(타입·정본·관 지점) — 카드 로드와 병행, 화면을 잠그지 않는다.
|
|
void structuresPanel.load().then(restoreStructurePick);
|
|
try {
|
|
const existing = await getSections(projectId, context.route_id);
|
|
if (!existing.longitudinal) {
|
|
leaveForDashboard();
|
|
return;
|
|
}
|
|
// 공유 캐시 — B05가 이미 받아 뒀으면 같은 객체를 즉시 재사용한다(두 페이지 싱크의 핵심).
|
|
sectionDetail = await loadSectionDetail(projectId, context.route_id);
|
|
// 구조물 초안(저장 안 한 벽)은 저장소가 이미 얹어 준다. 면적만 여기서 다시 얹으면
|
|
// 첫 화면의 카드·유토곡선이 초안 기준으로 선다(2026-09-06 실측).
|
|
if (sectionDetail) {
|
|
applyStructureAreaRows(
|
|
sectionDetail.cross_sections,
|
|
structureAreaRows(sectionDetail.cross_sections),
|
|
);
|
|
}
|
|
// 단일 소스(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);
|
|
/* 입력칸의 기준값은 **표시 기본 12m**(2026-08-19 사용자 지시, 위에서 이미 넣었다).
|
|
*
|
|
* ⚠ `data.options.cross_half_width_m`은 종단·횡단을 만들 때 쓴 **샘플링(계산)
|
|
* 반폭**이지 사용자가 고른 표시 폭이 아니다(엔진이 offsets 범위를 만들 때 쓰고
|
|
* 그대로 저장한다). 그 값으로 입력칸을 덮으면 표시 기본값이 언제나 계산 기본
|
|
* 20m으로 되돌아온다 — 2026-08-19 사용자 보고의 원인이 이것이다. 그래서 여기서는
|
|
* 읽지 않고, 사용자가 실제로 고른 표시 폭만 되살린다:
|
|
* ① 측점 개별 표시 반폭의 저장값(design.display_half_width_m) 대표값
|
|
* ② 세션에 남은 마지막 조절값(같은 노선으로 돌아왔을 때)
|
|
* 표시값이 보유 샘플 폭보다 크면 [전체 측점 반영]이 알아서 재생성한다. */
|
|
const savedDisplayWidths = sectionDetail.cross_sections
|
|
.map((section) => section.design?.display_half_width_m)
|
|
.filter((width): width is number => typeof width === "number" && width > 0);
|
|
if (savedDisplayWidths.length) {
|
|
// 측점마다 다를 수 있다 — 전역 입력칸에는 가장 넓은 값을 올린다(잘림 방지).
|
|
crossHalfWidthField.input.value = Math.max(...savedDisplayWidths).toFixed(1);
|
|
}
|
|
// 세션에 보관된 표시 반폭이 있으면 그것이 우선한다(사용자가 마지막으로 지정한 값).
|
|
const sessionDisplayKey = displaySessionKey();
|
|
if (sessionDisplayKey) {
|
|
const sessionDisplay = Number(readByKey(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;
|
|
renderSectionDetail();
|
|
// 측점이 없는 구조물 알림 — 관을 나중에 놓으면 그 측점이 안 생겨 수량에서 조용히 빠진다
|
|
// (계획서 3-14 ㉯). 만드는 것은 사용자가 누를 때만.
|
|
void mountMissingStationNotice(root, projectId, refreshDetailForStructures);
|
|
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");
|
|
}
|
|
}
|