Files
Aislo/A00_Common/design/design_view_profile.ts
T
eomsangdonandClaude Opus 5.5 d985c24f5c feat(종단보기): 345-3 노선 편집 → 종단 보기 가운데 위 본문(페이지 수명) · 종단도 판 흐름 배치 · [노선 반영] 뒤 새로고침 없이 다시 세움 · Ctrl+Z · Y 는 마지막에 만진 판 · 「계획노선」 칸 · 편집 단추 지움
- mountRouteEditor(parent, host, …) — 덮개 · 전체 화면 · 닫기 걷음 · 이력 · 키 듣기 = host 보임 · 숨김 · 떠남 · 칸 크기 = ResizeObserver
- [취소] → [반영 전으로](이력 처음으로 · 되돌리기로 다시 살림) · [확인] → [노선 반영] · 대기 막 = 화면 전체(노선 재탐색)
- [노선 반영] 끝 = 초안 · 결과 · 옛 노선 · 종단면 캐시 비움 → remountRoute(location.reload 없앰)
- R 라벨 = 칸 안 절대 위치 · 오른쪽 횡단 미리보기 = 접는 칸 · [3D] 단추 = 평면 머리 도구줄
- 새 _RouteEdit_UndoOwner(누른 판 기억) · 종단도 Ctrl+Z 줄이 물음
- 시험: test_345_3_route_body 5 · 칸 · 겉틀 시험 고쳐 씀 · 관련 411 통과(옛 3 실패 그대로) · typecheck 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-16 11:23:27 +09:00

524 lines
27 KiB
TypeScript

/* =============================================================================
* design_view_profile.ts
* 한 페이지의 **종단 보기**(계획서 24-3b) — 가운데 위 칸 = 평면(노선 편집 · 345-3) · 3D 화면은
* [3D] 큰 모달(345-2). 좌측 패널 · 아래 단추 ·
* 종단도(유토곡선 · 배수유역도)는 한 벌(`design_shared`)을 횡단 보기와 같은 노드로 쓴다.
*
* 여기서는 종단 보기 몫만 건다 — 3D · 좌측 종단 칸 재료 · 구조물 다리 · 종단도 알림 · 저장 몫.
* (옛 `B05_Profile_UI_Page.ts` 의 본체다. 버튼 동작은 `_Page_Actions`, 최신 노선 캐시는
* `_Page_Latest`, 3D 측점 바는 `_Page_Stations`, 숨김 · 보임은 `_Page_Life` 가 맡는다.
* 노선 · 종단면 그리기는 `design_view_profile_sections`, 3D 몫은 `design_view_profile_3d`.)
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import { purgeOtherProjects } from "../b_asset_cache";
import { createProgressCircle } from "@ui/ui_template_progress";
import { fetchConfirmedSurface } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { clearRouteLatestCache } from "../../B05_Profile/B05_Profile_Api_Fetch";
import { createRoutePanel } from "../../B05_Profile/B05_Profile_UI_Panel";
import { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_Profile_Panel";
import { leaveForDashboard } from "../b_missing_data_guard";
import { currentRoute, remountRoute } from "../router";
import { mountRouteEditor } from "../../B05_Profile/B05_Profile_UI_RouteEdit";
import { createSelectionSync, graphTargetAt } from "../../B05_Profile/B05_Profile_UI_Selection";
import {
readStructurePick,
restoreStructurePick,
wireStructurePick,
} from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import { coversAtStation, NO_SELECTION, readSelection, selectDesign } from "./design_selection";
import type { DesignPageHost } from "./design_page";
import { bindProfilePick } from "./design_pick_profile";
import {
bindDesignView,
designReady,
forwardPipeOptions,
type DesignPageShared,
} from "./design_shared";
import { bindProfileViewLife } from "../../B05_Profile/B05_Profile_UI_Page_Life";
import { createStructuresBridge } from "../../B05_Profile/B05_Profile_UI_Page_Structures";
import { createRouteViewer } from "../../B05_Profile/B05_Profile_UI_Viewer";
import {
irregularStationId,
isPipeStation,
} from "../../B05_Profile/B05_Profile_UI_IrregularStations";
import {
rememberRockBoundaryDefault,
rememberStandardDefaults,
} from "../../B06_Section/B06_Section_UI_Standard_Panel";
import { fetchSectionContext } from "../../B06_Section/B06_Section_Api_Fetch";
import { invalidateSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
import {
onStaleStructureTypes,
structureAnchorM,
} from "../../B05_Profile/B05_Profile_Api_Structures";
import { pipeToFacilityItem } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types";
import { saveCorridorIfDirty } from "../../B05_Profile/B05_Profile_UI_Corridor";
import {
profileSaveParts,
type PageActionContext,
} from "../../B05_Profile/B05_Profile_UI_Page_Actions";
import { createLatestSource } from "../../B05_Profile/B05_Profile_UI_Page_Latest";
import { setPipeCrossOpener, setPipeSectionsSource } from "./design_structure_pipe_calc";
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
import "../../B05_Profile/B05_Profile_UI_Style.css";
// 700줄 제한으로 잘라낸 조각 — 본체 **다음에** 불러야 캐스케이드 순서가 같다(2026-09-04).
import "../../B05_Profile/B05_Profile_UI_Style_Table.css";
import "../../B05_Profile/B05_Profile_UI_Style_Drainage.css";
import "../../B05_Profile/B05_Profile_UI_Style_Structures.css";
import { pipeIdAt } from "./design_pipe_ids";
import {
DEFAULT_ROAD_WIDTHS,
fetchRoadWidths,
loadUphillOverrides,
saveUphillOverrides,
toGradeClass,
} from "../../B05_Profile/B05_Profile_UI_Page_Helpers";
import { createProfileSections, type ProfileViewState } from "./design_view_profile_sections";
import { createProfile3d } from "./design_view_profile_3d";
type PipeReport = Parameters<NonNullable<RouteProfilePanelCallbacks["onPipesChanged"]>>[0];
export async function mountProfileView(
center: HTMLElement,
host: DesignPageHost,
shared: DesignPageShared,
): Promise<void> {
const activeProjectId = shared.projectId;
if (!activeProjectId) return;
// 새로고침으로 바로 들어온 경우에도 다른 프로젝트 자료는 보관함에서 지운다.
void purgeOtherProjects(activeProjectId);
const viewer = createRouteViewer();
// 구 우클릭 메뉴의 자유 텍스트 구조물 → 레지스트리 타입(2026-08-17 이관 매핑과 동일).
const LEGACY_TYPE_IDS: Record<string, string> = {
기성막이: "revetment",
대피로: "refuge",
기타: "etc",
};
/** 고른 것을 selection 한 곳에 적는다 — 횡단 보기로 이어지는 값이다(chainageM null = 해제). */
const rememberPick = (chainageM: number | null, structureId: string | null): void =>
selectDesign(activeProjectId, chainageM === null ? NO_SELECTION : { chainageM, structureId }, {
from: "b05",
});
/** 배수유역도가 마지막으로 알린 관 목록 — 숨어 있다 다시 보일 때 폼 · 3D 에 다시 싣는다. */
let lastPipes: PipeReport | null = shared.lastPipes ?? null;
const applyPipes = (pipes: PipeReport): void => {
// 횡단배수는 계획선을 그 지점에 물리는 시설이라 그래프 세로 점선·계획고 틸팅
// 버튼이 필요하다(2026-08-17 사용자 지시). 알약 레인은 종류·선택을 맡는다.
bridge.setPipes(pipes);
panel.structures.setPipeFacilities(pipes.map(pipeToFacilityItem));
profilePanel.setMinCoverPipes(pipes);
snapOld();
restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다.
};
/** 측점선 강조 — 측점선이 없는 관 시설(물넘이포장 · 비정규 측점 아님)은 알약을 id 로 켬(53-2) ·
* 관도 없는 자리면 그 측점선 · 3D 띠(횡단에서 고른 일반 측점 · 188-1) — 구조물 고름은 선 없음(189-1). */
const selectGraphAt = (id: string | null, chainageM: number | null): void => {
const structurePick = !!readSelection(activeProjectId)?.structureId;
const sections = structurePick ? [] : (state.detail?.cross_sections ?? []);
const target = graphTargetAt(id, chainageM, lastPipes ?? [], sections);
if ("pipeChainageM" in target)
return profilePanel.setSelectedStructure(pipeIdAt(target.pipeChainageM, activeProjectId));
profilePanel.setSelectedStation(target.stationId);
if (id === null && target.stationId !== null) viewer.markers.selectStation(target.stationId);
};
// 종단도 한 벌 — 종단 보기가 보일 때 받는 알림만 건다(계획서 24-3b).
const profilePanel = createRouteProfilePanel(
shared,
(stationId) => {
viewer.markers.selectStation(stationId);
syncIrregularSelection(stationId);
},
// [초기선 복원] 시 그래프의 배관 투영도 지운다(관 정본은 배수유역 초기화가 맡는다).
() => bridge.clearProjectedStations(),
{
// 계획선 편집 프리뷰가 횡단 설계선을 갱신해도 3D는 **자동으로 따라오지 않는다**
// (2026-09-01 사용자 지시). 갱신은 상단 [3D 업데이트] 버튼으로 모았다.
onCrossDesignsUpdated: () => panel.setCorridorPending(true),
// 관 매설 목록 → 그래프 배관 측점선·통합 목록을 한 방향으로 맞춘다. 정본은 배수유역도의
// 관 지점이다. 숨어 있을 때는 담아만 두고 다시 보일 때 싣는다(폼은 한 벌이라 횡단 보기 몫).
onPipesChanged: (pipes) => {
lastPipes = pipes;
if (host.visible()) applyPipes(pipes);
},
onStructureMove: (from, to, station) => {
if (isPipeStation(station)) profilePanel.drainage.movePipe(from, to);
},
onStructureRemove: (station) => {
if (isPipeStation(station)) profilePanel.drainage.removePipe(station.chainage_m);
},
// 우클릭 [배관 추가] — 사이드 폼에 종류·측점을 실어 주고 [추가]로 확정(2026-08-18).
onPipeAdd: (chainage) => panel.structures.addAt(chainage, "pipe"),
onStructureAdd: (chainage, type) =>
panel.structures.addAt(chainage, LEGACY_TYPE_IDS[type] ?? "etc"),
onIrregularSelect: (station) => syncIrregularSelection(irregularStationId(station.id)),
// 배수유역도에서 유역·관 마커를 고르면 그 관의 측점을 그래프·3D·사이드 패널에서도 고른다.
onBasinSelected: (chainageM) => pickFromMap(chainageM),
onPipeSelected: (chainageM) => pickFromMap(chainageM),
// 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설
// (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17).
onStructureSelect: (structureId) => {
const chainage = bridge.pipeMarkChainage(structureId);
// 측점 없는 관(물넘이포장)은 알약 id 도 남김 — 같은 m 일반 측점선을 안 켬(요구 178)
const keepId = chainage === null || bridge.stationlessPipeMark(structureId);
rememberPick(bridge.markChainage(structureId), keepId ? structureId : null);
if (chainage !== null) {
panel.structures.selectPipeByChainage(chainage);
selectStationOfPipe(chainage);
return;
}
panel.structures.selectById(structureId);
},
onStructureMarkMove: (structureId, toChainage) => {
const chainage = bridge.pipeMarkChainage(structureId);
if (chainage !== null) {
profilePanel.drainage.movePipe(chainage, toChainage);
return;
}
panel.structures.moveById(structureId, toChainage);
},
onStructureTypeAdd: (chainage, typeId) => panel.structures.addAt(chainage, typeId),
},
);
const state: ProfileViewState = {
confirmedSurface: null,
latest: null,
roadWidths: DEFAULT_ROAD_WIDTHS,
detail: null,
corridorVisible: true,
routeReady: false,
restoring: true,
};
// 배관 관 길이 · 흙막이 개수(54-1) — 종단 보기도 같은 횡단 자료로(B05 = B06)
setPipeSectionsSource(() => state.detail?.cross_sections ?? null);
// 좌측 단 수를 바꾸면 횡단 보기를 숨긴 채 세워 같은 단 수 제어로 씀(54-27)
setPipeCrossOpener(() => shared.ensureView("cross"));
// 선택 동기화 재진입 가드(3D↔그래프↔사이드바 상호 갱신의 무한 재귀 차단).
let selectionSyncing = false;
/* ── 측점 상단측(=측구 방향) 사용자 변경분 ─────────────────────────────
* solve가 자동 판정한 uphill_side를 사용자가 바꾼 값. 세션에 보관했다가
* 저장 시 uphill_overrides로 백엔드/DB(종단 정본)에 병합한다(3D 구슬 입력은 176-3 에서 지움). */
const uphillOverrides = loadUphillOverrides(activeProjectId);
const { syncIrregularSelection, selectStationOfPipe } = createSelectionSync({
stations: () => bridge.irregularStations(),
isSyncing: () => selectionSyncing,
setSyncing: (value) => {
selectionSyncing = value;
},
selectMarker: (id) => viewer.markers.selectStation(id),
selectGraph: (id, chainageM) => selectGraphAt(id, chainageM ?? null),
selectSidebar: (chainageM) => panel.structures.selectPipeByChainage(chainageM),
// 측점선 · 3D 띠 고름 = 횡단 카드 고름(B06 showAtChainage)과 같은 규칙 · 같은 함수(216-1)
openAtStation: (chainageM) =>
panel.structures.selectAt({ chainageM, structureId: null }, (entry) =>
coversAtStation(bridge.typeGroup(entry.type_id)),
),
selectBasin: (chainageM) => profilePanel.drainage.selectBasinByChainage(chainageM),
selectPipeForm: (chainageM) => profilePanel.drainage.selectPipeAtChainage(chainageM),
markStation: (chainageM) => profilePanel.drainage.markStation(chainageM),
selectStructure3D: (chainageM) => viewer.structurePick.selectAtChainage(chainageM),
regularChainage: (stationId) =>
state.detail?.cross_sections.find((entry) => entry.station_id === stationId)?.chainage_m,
remember: (chainageM) => rememberPick(chainageM, null),
});
/** 배수유역도 유역 · 관 고름도 고름 한 칸에(사슬 도중 되울림은 안 적음 · 233-3). */
const pickFromMap = (chainageM: number | null): void => {
if (!selectionSyncing) rememberPick(chainageM, null);
selectStationOfPipe(chainageM);
};
let oldSnapped = false;
/** 옛 소수 측점(관 · 구조물)을 정수 m 로 — 관 · 구조물 · 계획고 편집이 다 실린 뒤 한 번(272-4). */
function snapOld(): void {
if (oldSnapped || state.restoring || !lastPipes) return;
oldSnapped = true;
panel.structures.snapOld();
}
/** 세션에 남은 구조물 선택을 되살린다 — 횡단 보기에서 고른 것도 그대로 이어 받는다
* (2026-09-04). 이미 무언가 골라져 있으면 건드리지 않는다. */
function restorePick(): void {
profilePick.paint(); // 관 · 구조물 목록이 바뀐 뒤에도 종단은 고름 한 칸대로(233-1)
if (panel.structures.hasSelection()) return;
const pick = viewer.structurePick;
restoreStructurePick(pick, activeProjectId, panel.structures, selectStationOfPipe);
}
const latestSource = createLatestSource(activeProjectId);
const loadLatest = latestSource.load;
const sections = createProfileSections({
projectId: activeProjectId,
state,
viewer,
panel: () => panel,
profilePanel: () => profilePanel,
bridge: () => bridge,
});
const { restorePanel, renderLatest, restoreSections } = sections;
const view3d = createProfile3d({
projectId: activeProjectId,
state,
viewer,
host,
latestSource,
profilePanel: () => profilePanel,
sections: () => sections,
});
const panel = createRoutePanel(
{
onContourApply: (interval) => view3d.applyContours(interval),
...view3d.viewCallbacks,
onInputChange: markStale,
onStationDisplayChange: (offset) => profilePanel.setStationDisplay(offset),
onStructuresChange: (next) => bridge.applyStructures(next),
// 계곡 통과 시설(A군) — 관 지점 정본(배수유역 패널) 경유(2026-08-17 통합).
onPipeFacilityAdd: (chainage, attributes) =>
profilePanel.drainage.addPipe(chainage, attributes),
onPipeFacilityUpdate: (from, to, attributes) => {
profilePanel.drainage.updatePipeFacility(from, to, attributes);
// 한 벌 폼 — 횡단 보기 캐시(세월교 · BOX 스펙)도 같은 값으로(계획서 24-3b).
forwardPipeOptions(shared, from, attributes.options as Record<string, number | string>);
},
onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage),
// 목록 고름(null = 다시 누름 해제)도 고름 한 칸에 — 종단 · 3D · 배수유역도는 구독이 그 값으로 그림(233-3)
onPipeFacilitySelect: (chainage) => {
rememberPick(chainage, null);
if (!selectionSyncing) profilePick.paint();
},
onListRelease: () => rememberPick(null, null), // 목록 다시 누름 = 풀기 한 길(233-3)
onStructureSelect: (structure) => {
// 목록에서 고른 구조물도 횡단 보기로 이어진다 — 해제(null)는 여기서 적지 않는다(관 폼을
// 여는 길이 구조물 폼을 먼저 비우며 이 콜백을 null 로 부르기 때문).
if (structure) rememberPick(structureAnchorM(structure), structure.structure_id ?? null);
// 폼이 닫혀도(저장 뒤 · 관 폼으로) 종단 · 3D 는 고름 한 칸대로(233-1 · 233-2)
if (!selectionSyncing) profilePick.paint();
},
},
shared,
);
// 3D 부재 고름도 측점 고름과 같은 거름 — 노면공 id 를 고름 한 칸에 적지 않음(231-1)
wireStructurePick(
viewer.structurePick,
activeProjectId,
panel.structures,
selectStationOfPipe,
(entry) => coversAtStation(bridge.typeGroup(entry.type_id)),
);
/** 입력·마커 변경 시 측점 라인만 다시 그린다. 지형 구분·등급·기울기 세부 입력은 종단 그래프의
* 위반 판정 기준까지 **즉시** 갈아 끼운다(2026-08-19 사용자 지시 6). */
function markStale(): void {
if (state.restoring || !state.routeReady) return;
profilePanel.setGradeLimit(panel.gradeLimitPct());
// 횡단배수 최소고 강제 체크는 재계산 없이 편집 가드에만 즉시 반영된다(2026-09-01).
profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance);
sections.redrawStationLines();
}
viewer.markers.onChange(markStale);
viewer.markers.onStationSelectionChange((stationId) => {
profilePanel.setSelectedStation(stationId);
syncIrregularSelection(stationId);
});
viewer.root.append(panel.viewControls);
/** 구조물·계곡 통과 시설을 사이드 목록·그래프·3D에 맞추는 다리(정본 저장까지 맡는다). */
const bridge = createStructuresBridge({
projectId: activeProjectId,
panel: () => panel,
profilePanel: () => profilePanel,
renderStationLines: () => sections.redrawStationLines(),
isRestoring: () => state.restoring,
});
// 종단도 · 3D 강조 = 고름 한 칸에서 파생 · 이 보기가 보일 때만(횡단 보기는 제 규칙 · 233-1 · 233-2)
const profilePick = bindProfilePick(
activeProjectId,
{ profile: profilePanel, viewer, form: panel.structures },
host,
{
irregular: () => bridge.irregularStations(),
pipes: () => lastPipes ?? [],
sections: () => state.detail?.cross_sections ?? [],
},
);
// 3D 노선 우클릭 → 구조물 배치 메뉴(그래프·배수유역도와 동일, 2026-08-19).
bridge.mountViewerMenu(viewer, () => state.latest?.route_points ?? []);
// [경로 계산] · 저장 몫은 따로 뗀 모듈이 맡는다(2026-09-02).
const actionContext: PageActionContext = {
projectId: activeProjectId,
latest: () => state.latest,
confirmedSurface: () => state.confirmedSurface,
routeReady: () => state.routeReady,
viewer: () => viewer,
panel: () => panel,
profilePanel: () => profilePanel,
bridge: () => bridge,
uphillOverrides,
persistUphillOverrides: () => saveUphillOverrides(activeProjectId, uphillOverrides),
loadLatest,
renderLatest,
restoreSections,
};
// [저장] · [확정] · [초기화]에 보태는 종단 보기 몫 — 한 벌이 두 보기 몫을 모아 보낸다.
bindDesignView(shared, "profile", {
save: profileSaveParts(actionContext),
onReset: () => uphillOverrides.clear(),
});
// 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0)
const reloadIfShown = (): void => void (host.visible() && !state.restoring && bridge.load());
host.onDispose(onStaleStructureTypes(reloadIfShown));
// 한 페이지 두 보기 — 숨김 · 보임 · 떠남(계획서 24-3a · 24-3b).
bindProfileViewLife(host, {
projectId: activeProjectId,
// 3D 는 모달 — 숨으면 닫음(그리기 멈춤) · 보여도 모달을 열 때만 그림 · 떠나면 버림(345-2).
viewer: {
setActive: (active) => {
if (!active) view3d.modal.close();
},
dispose: view3d.modal.dispose,
},
ready: () => !state.restoring,
// 다시 보이면 횡단 보기가 한 벌(폼 · 종단도)에 실어 둔 값을 종단 보기 값으로 되돌린다.
reload: async () => {
await bridge.load();
if (lastPipes) applyPipes(lastPipes);
},
syncPick: () => {
if (readStructurePick(activeProjectId)) {
restoreStructurePick(
viewer.structurePick,
activeProjectId,
panel.structures,
selectStationOfPipe,
);
return;
}
profilePick.paint();
},
// 보기를 떠나는 자리 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다.
onHide: () => {
if (state.latest?.route?.id) void saveCorridorIfDirty(activeProjectId, state.latest.route.id);
},
});
/** [노선 반영] 끝 — 서버가 배수유역부터 다시 셌고 그 결과가 새 초기값이다. 새로고침 대신 페이지를
* 통째로 다시 세운다(345-3) — 제자리 고침은 옛 노선 관 목록 등 메모리 · 진행 중 요청이 살아남아
* [저장]에 실렸다(PLAN 77). 초안 · 결과 칸은 [노선 반영]이 비웠고 옛 노선 캐시는 여기서 비운다 ·
* 떠나는 페이지의 늦은 응답은 `host.disposed()` 로 버려진다. */
const rebuildAfterReplan = (): void => {
clearRouteLatestCache(activeProjectId);
invalidateSectionDetail(activeProjectId);
remountRoute(currentRoute());
};
/* ── 진입 로딩 ─────────────────────────────────────────────────────────
* 화면 틀을 먼저 띄우고 자료가 끝나는 순서대로 채운다(2026-08-01 사용자 지시).
* 3D 지형은 **보조 자료라 로딩에 넣지 않는다**(2026-09-04 사용자 지시) — [3D] 모달을 처음 열 때
* 올린다(345-2). 서클은 가운데 위 칸 정중앙에 둔다(하단 종단 패널에 가려지는 것은 무방). */
const LOAD_STEP_COUNT = 3;
const mainContent = document.createElement("div");
mainContent.className = "b05-route__main";
mainContent.append(view3d.modal.park);
center.append(mainContent);
const progress = createProgressCircle({ label: "노선 정보를 불러오는 중…", overlay: true });
mainContent.append(progress.root);
let loadedSteps = 0;
function advanceLoading(label: string): void {
loadedSteps += 1;
progress.set(Math.min(1, loadedSteps / LOAD_STEP_COUNT), label);
}
try {
// ① 좌측 폼·노선 설정값 — 캐시가 있으면 그것으로 **먼저** 그리고 신선도는 뒤에서 확인한다
// (2026-09-06). 캐시가 없을 때만 DB를 기다린다.
const cachedLatest = latestSource.read();
const [latestResponse, sectionContext, configuredRoadWidths] = await Promise.all([
cachedLatest ? Promise.resolve(cachedLatest) : loadLatest(true),
fetchSectionContext(activeProjectId),
fetchRoadWidths(activeProjectId),
]);
if (host.disposed()) return;
state.roadWidths = configuredRoadWidths;
panel.restore({
stationInterval: sectionContext.defaults.station_interval_m,
crossSampleInterval: sectionContext.defaults.cross_sample_interval_m,
longSampleInterval: sectionContext.defaults.long_sample_interval_m,
// 임도 종류는 프로젝트 등록(B02)이 정본이다 — 화면에서 고르지 않는다(2026-08-19).
gradeClass: toGradeClass(sectionContext.road_type),
});
// 유토곡선용 설정 — config_system 정의 그대로 받아 횡단 보기와 같은 계산을 태운다.
profilePanel.setEarthworkContext({
conversion: sectionContext.earthwork_conversion,
haulLimits: sectionContext.haul_equipment_limits,
naturalSpoilMinSlope: sectionContext.natural_spoil_min_ground_slope ?? undefined,
});
// 표준횡단 config 기본값도 기억한다 — 계획선 편집 중 횡단 재계산이 브라우저에서 돈다
// (2026-09-03 로컬 계산 전환). 프론트에 config 사본을 두지 않으려는 같은 원칙이다.
rememberStandardDefaults(activeProjectId, sectionContext.standard_cross_section);
rememberRockBoundaryDefault(activeProjectId, sectionContext.rock_boundary_default_offset_m);
restorePanel(latestResponse);
renderLatest(latestResponse);
advanceLoading("확정 지표면 모델을 확인하는 중…");
// ② 확정 지표면 — 모델 id·범위를 한 번에 받는다.
const confirmed = await fetchConfirmedSurface(activeProjectId);
state.confirmedSurface = confirmed.model_id === null ? null : confirmed;
if (!state.confirmedSurface) {
// 새 자료가 올라와 옛 결과가 지워진 상태 — 여기서 보여 줄 게 없다.
leaveForDashboard();
return;
}
advanceLoading("종단면 자료를 불러오는 중…");
// 평면(노선 편집) — 가운데 위 본문 · 페이지 수명(345-3). 자료는 페이지 열기와 함께 읽는다.
void mountRouteEditor(mainContent, host, activeProjectId, rebuildAfterReplan, {
// 측점 눈금 간격은 좌측 패널이 쥔 값을 그대로 넘긴다 — 편집기가 따로 굳히지 않는다.
stationIntervalM: panel.values().stationInterval ?? undefined,
// 바탕 등고선 — 확정 지표면이 있으면 LAS 등고선을 쓴다(계획서 0-9 ⑥).
surfaceModelId: state.confirmedSurface.model_id,
contourIntervalM: latestResponse.surface_params.contour_interval_m,
smooth: latestResponse.surface_params.smooth,
tools: [view3d.modal.button],
});
// ③ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다.
if (latestResponse.route) await restoreSections(latestResponse.route.id);
// 횡단 보기가 먼저 서서 배수유역도가 관 목록을 이미 알렸다면 그것을 먼저 싣는다 — 빈 목록으로
// 시작하면 아래 구조물 로딩이 관 대조로 관을 지우고 초안 옵션이 자동 배치로 되돌아간다(24-7a).
if (lastPipes) applyPipes(lastPipes);
// 구조물 타입 레지스트리·정본 — 노선이 없어도 목록은 보여 준다(추가는 노선 이후).
await bridge.load();
advanceLoading("");
designReady(shared);
// 캐시로 그렸다면 뒤에서 신선도만 확인한다 — 다른 탭이 자료를 갈아 끼웠을 때만 다시 그린다.
if (cachedLatest) {
void latestSource.verify(latestResponse, async (fresh) => {
// 노선 자체가 바뀌었으면 횡단 보기 · 측점 · 구조물도 옛 노선을 쥐고 있다 — 선만 다시 그리면
// 옛 노선 표지 · 옛 id 저장이 남는다(PLAN 76-1). 설정값 캐시를 버리고 페이지를 새로 세운다.
if (fresh.route?.id !== latestResponse.route?.id) {
clearRouteLatestCache(activeProjectId);
remountRoute(currentRoute());
return;
}
renderLatest(fresh);
if (fresh.route?.id) await restoreSections(fresh.route.id);
});
}
} catch (error) {
showToast(error instanceof Error ? error.message : "화면을 불러오지 못했습니다.", "error");
} finally {
progress.remove();
state.restoring = false;
snapOld();
restorePick(); // 관·구조물 목록 중 늦게 오는 쪽이 있어 여기서 한 번 더.
}
}