Files
Aislo/B05_Profile/B05_Profile_UI_Page.ts
T
eomsangdonandClaude Fable 5 54954a05e5 refactor(B05,B06): B05_wf2_Route -> B05_Profile, B06_wf3_ProfileCross -> B06_Section 동시 개명
- 한몸으로 동작하는 두 페이지라 한 커밋으로 처리 (상호 참조 다수)
- B05 37파일 + B06 20파일 접두사 개명 (git mv, 이력 보존)
- 참조 치환 91파일: import 경로, 라우트 슬러그(b05-profile/b06-section),
  라우트 키(B05_PROFILE/B06_SECTION), B03 자동 체인, storage 상수, pyproject 제외 경로
- 로직 변경 없음. typecheck·백엔드 import 검증 통과

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 10:03:11 +09:00

719 lines
31 KiB
TypeScript

import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
import { purgeOtherProjects } from "../A00_Common/b_asset_cache";
import { createProgressCircle } from "@ui/ui_template_progress";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import {
fetchWorkflowState,
goToWorkflowStage,
WORKFLOW_STEP_ROUTES,
} from "../A00_Common/b_workflow_nav";
import {
fetchConfirmedSurface,
listSurfaceModels,
type SurfaceModelSummary,
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
confirmRoute,
fetchLatestRoute,
readRouteLatestCache,
writeRouteLatestCache,
solveRoute,
updateContourInterval,
type CirclePoint,
type RouteLatestResponse,
type RoutePoint,
} from "./B05_Profile_Api_Fetch";
import {
type ModelBounds,
type PlacedRoutePoint,
type RouteDesignPoints,
type RoutePointKind,
} from "./B05_Profile_UI_Markers";
import { createRoutePanel, type RoutePanelValues } from "./B05_Profile_UI_Panel";
import { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel";
import { createSelectionSync } from "./B05_Profile_UI_Selection";
import { createRouteViewer } from "./B05_Profile_UI_Viewer";
import {
irregularLabel,
irregularStationId,
isPipeStation,
type IrregularStation,
} from "./B05_Profile_UI_IrregularStations";
import {
fetchSectionContext,
type SectionDetailResponse,
type SectionStation,
} from "../B06_Section/B06_Section_Api_Fetch";
import {
invalidateSectionDetail,
loadSectionDetail,
} from "../B06_Section/B06_Section_Section_Store";
import "./B05_Profile_UI_Style.css";
type GradeClass = RoutePanelValues["gradeClass"];
type RoadWidths = Record<GradeClass, number>;
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
const DEFAULT_ROAD_WIDTHS: RoadWidths = { trunk: 3, branch: 3, work: 2.5 };
async function fetchRoadWidths(projectId: string): Promise<RoadWidths> {
const response = await fetch(`/api/projects/${projectId}/sections/road-widths`);
if (!response.ok) return DEFAULT_ROAD_WIDTHS;
const payload = (await response.json()) as { forest_road_min_width_m?: Partial<RoadWidths> };
return { ...DEFAULT_ROAD_WIDTHS, ...payload.forest_road_min_width_m };
}
function toBounds(bounds: {
x_min: number;
x_max: number;
y_min: number;
y_max: number;
z_min: number;
z_max: number;
}): ModelBounds {
return {
x: [bounds.x_min, bounds.x_max],
y: [bounds.y_min, bounds.y_max],
z: [bounds.z_min, bounds.z_max],
};
}
function placed(
type: RoutePointKind,
point: RoutePoint | CirclePoint,
index = 0,
): PlacedRoutePoint {
return {
id: `${type}-restored-${index}`,
type,
x: point.x,
y: point.y,
z: point.z ?? 0,
...(type === "ap" || type === "fp" ? { radius_m: (point as CirclePoint).radius_m ?? 25 } : {}),
};
}
function restorePoints(latest: RouteLatestResponse): RouteDesignPoints {
const points = latest.route_params?.points;
return {
bp: points?.bp ? placed("bp", points.bp) : null,
ep: points?.ep ? placed("ep", points.ep) : null,
cp: (points?.cp ?? []).map((point, index) => placed("cp", point, index)),
ap: (points?.ap ?? []).map((point, index) => placed("ap", point, index)),
fp: (points?.fp ?? []).map((point, index) => placed("fp", point, index)),
};
}
function routePoint(point: PlacedRoutePoint): RoutePoint {
return { x: point.x, y: point.y, z: point.z };
}
function circlePoint(point: PlacedRoutePoint): CirclePoint {
return { ...routePoint(point), radius_m: point.radius_m ?? 25 };
}
/**
* 비정규 측점을 규칙 측점 좌표 사이 chainage로 선형보간해 `SectionStation`(월드 좌표·프레임 포함)으로
* 만든다. 백엔드가 아직 이 측점의 횡단을 생성하지 않으므로, 3D 표시에 필요한 위치만 근사한다.
* 노선 범위를 벗어난 chainage는 제외한다.
*/
function interpolateIrregularStations(
base: SectionStation[],
list: IrregularStation[],
maxChainage: number,
): SectionStation[] {
const sorted = [...base].sort((a, b) => a.chainage_m - b.chainage_m);
if (!sorted.length) return [];
const anchorAt = (chainage: number): SectionStation => {
if (chainage <= sorted[0].chainage_m) return sorted[0];
const last = sorted[sorted.length - 1];
if (chainage >= last.chainage_m) return last;
let lo = sorted[0];
let hi = last;
for (let index = 1; index < sorted.length; index += 1) {
if (sorted[index].chainage_m >= chainage) {
lo = sorted[index - 1];
hi = sorted[index];
break;
}
}
const span = hi.chainage_m - lo.chainage_m;
const t = span > 1e-9 ? (chainage - lo.chainage_m) / span : 0;
const lerp = (a: number, b: number): number => a + (b - a) * t;
const centerZ =
lo.center_z !== null && hi.center_z !== null
? lerp(lo.center_z, hi.center_z)
: (lo.center_z ?? hi.center_z);
return {
...lo,
center_x: lerp(lo.center_x, hi.center_x),
center_y: lerp(lo.center_y, hi.center_y),
center_z: centerZ,
frame: {
left_xy: [
lerp(lo.frame.left_xy[0], hi.frame.left_xy[0]),
lerp(lo.frame.left_xy[1], hi.frame.left_xy[1]),
],
},
};
};
return list
.filter((entry) => entry.chainage_m >= 0 && entry.chainage_m <= maxChainage + 1e-6)
.map((entry) => ({
...anchorAt(entry.chainage_m),
station_id: irregularStationId(entry.id),
chainage_m: entry.chainage_m,
label: irregularLabel(entry),
kind: "irregular" as const,
// 3D 측점 라벨이 `측점번호 구조물명`으로 표기할 수 있게 구조물 이름을 실어 보낸다.
structure: entry.structure,
}));
}
export async function renderB05Route(root: HTMLElement): Promise<void> {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
if (!projectId) {
showToast("프로젝트를 먼저 선택하세요.", "error");
return;
}
const activeProjectId: string = projectId;
// 새로고침으로 바로 들어온 경우에도 다른 프로젝트 자료는 보관함에서 지운다.
void purgeOtherProjects(activeProjectId);
const viewer = createRouteViewer();
const profilePanel = createRouteProfilePanel(
activeProjectId,
(stationId) => {
viewer.markers.selectStation(stationId);
syncIrregularSelection(stationId);
},
// [초기선 복원] 시 추가한 비정규 측점도 함께 지운다.
() => panel.irregularStations.clear(),
{
// 관 매설 목록 ↔ 구조물 목록의 "배관" 항목을 한 방향으로 맞춘다.
// 정본은 배수유역도의 관 지점이며, 구조물 목록은 그것을 실체화한 것이다.
onPipesChanged: (pipes) => panel.irregularStations.setPipeStations(pipes),
onStructureMove: (from, to, station) => {
if (isPipeStation(station)) {
// 배관은 관 지점 정본을 거쳐야 세부유역까지 함께 다시 나뉜다.
profilePanel.drainage.movePipe(from, to);
return;
}
panel.irregularStations.moveByChainage(from, to);
},
onStructureRemove: (station) => {
if (isPipeStation(station)) {
profilePanel.drainage.removePipe(station.chainage_m);
return;
}
panel.irregularStations.removeByChainage(station.chainage_m);
},
onPipeAdd: (chainage) => profilePanel.drainage.addPipe(chainage),
// 우클릭 메뉴로 배관 외 구조물 추가(종단·배수유역도 공용) — 기본 옵션으로 넣는다.
onStructureAdd: (chainage, type) => panel.irregularStations.addStructure(chainage, type),
onIrregularSelect: (station) => syncIrregularSelection(irregularStationId(station.id)),
// 배수유역도에서 유역을 고르면 그 관의 구조물 측점을 그래프·3D·사이드 패널에서도 고른다.
onBasinSelected: (chainageM) => selectStationOfPipe(chainageM),
},
);
/**
* 그래프·3D에서 비정규 측점을 고르면 사이드바 입력 폼에 로드해 수정/삭제할 수 있게 한다.
* 선택은 3D·그래프·사이드바가 서로를 갱신하므로, `selectionSyncing` 가드로 재진입을 막아
* 무한 재귀(스택 오버플로우·프리즈)를 방지한다.
*/
let confirmedSurface: SurfaceModelSummary | null = null;
let latest: RouteLatestResponse | null = null;
let roadWidths = DEFAULT_ROAD_WIDTHS;
let currentSectionDetail: SectionDetailResponse | null = null;
let routeReady = false;
let stale = false;
let restoring = true;
let irregularStations: IrregularStation[] = [];
// 선택 동기화 재진입 가드(3D↔그래프↔사이드바 상호 갱신의 무한 재귀 차단).
let selectionSyncing = false;
/* ── 측점 상단측(=측구 방향) 사용자 변경분 ─────────────────────────────
* solve가 자동 판정한 uphill_side를 3D 램프 클릭으로 바꾼 값. 세션에 보관했다가
* 경로 확정 시 uphill_overrides로 백엔드/DB(종단 정본)에 병합한다. */
const uphillSessionKey = `b05:uphill:${activeProjectId}`;
const uphillOverrides = new Map<string, "left" | "right">();
const uphillKey = (chainage: number): string => chainage.toFixed(3);
try {
const raw = window.sessionStorage.getItem(uphillSessionKey);
if (raw) {
Object.entries(JSON.parse(raw) as Record<string, "left" | "right">).forEach(
([chainage, side]) => {
if (side === "left" || side === "right") uphillOverrides.set(chainage, side);
},
);
}
} catch {
/* 손상된 세션 값은 무시 — 자동 판정값으로 재시작. */
}
const { syncIrregularSelection, syncBasinHighlight, selectStationOfPipe } = createSelectionSync({
stations: () => irregularStations,
isSyncing: () => selectionSyncing,
setSyncing: (value) => {
selectionSyncing = value;
},
selectMarker: (id) => viewer.markers.selectStation(id),
selectGraph: (id) => profilePanel.setSelectedStation(id),
selectSidebar: (chainageM) => panel.irregularStations.selectByChainage(chainageM),
selectBasin: (chainageM) => profilePanel.drainage.selectBasinByChainage(chainageM),
markStation: (chainageM) => profilePanel.drainage.markStation(chainageM),
});
function persistUphillOverrides(): void {
try {
window.sessionStorage.setItem(
uphillSessionKey,
JSON.stringify(Object.fromEntries(uphillOverrides)),
);
} catch {
/* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */
}
}
/* ── 최신 경로/설정값 세션 캐시 ────────────────────────────────────────
* 확정 이력이 있으면 매 진입마다 DB(latest) 조회 대신 브라우저 세션 캐시를
* 우선 사용해 응답속도를 높인다. 캐시 미스면 latest를 조회해 적재하고,
* solve·확정 성공 시 신선한 값으로 갱신한다(세션 = 탭 단위, 탭 종료 시 소멸). */
const readLatestCache = (): RouteLatestResponse | null => readRouteLatestCache(activeProjectId);
const writeLatestCache = (value: RouteLatestResponse): void =>
writeRouteLatestCache(activeProjectId, value);
/** 캐시 우선 latest 로드. forceFresh=true(solve/확정 직후)는 항상 DB를 읽고 캐시를 갱신한다. */
async function loadLatest(forceFresh = false): Promise<RouteLatestResponse> {
if (!forceFresh) {
const cached = readLatestCache();
if (cached) return cached;
}
const fresh = await fetchLatestRoute(activeProjectId);
writeLatestCache(fresh);
return fresh;
}
const panel = createRoutePanel({
onSolve: () => void solve(),
onConfirm: () => void confirm(),
onContourApply: (interval) => void applyContours(interval),
onSurfaceVisible: viewer.setSurfaceVisible,
onContoursVisible: viewer.setContoursVisible,
onAxesVisible: viewer.setAxesVisible,
onStationLinesVisible: viewer.setStationLinesVisible,
onStationLabelsVisible: viewer.setStationLabelsVisible,
onSurfaceGrayscale: viewer.setSurfaceGrayscale,
onView: viewer.setView,
onResetView: () => viewer.setView("top"),
onMovePoint: viewer.beginMoveSelected,
onDeletePoint: viewer.markers.deleteSelected,
onRadiusChange: (radius) => viewer.markers.updateSelected({ radius_m: radius }),
onInputChange: markStale,
onIrregularChange: (stations) => applyIrregularStations(stations),
onStationDisplayChange: (offset) => profilePanel.setStationDisplay(offset),
onIrregularSelect: (station) => {
if (selectionSyncing) return;
selectionSyncing = true;
try {
const id = station ? irregularStationId(station.id) : null;
viewer.markers.selectStation(id);
profilePanel.setSelectedStation(id);
// 사이드 패널에서 배관 항목을 고르면 배수유역도의 그 유역도 함께 강조한다.
syncBasinHighlight(id);
} finally {
selectionSyncing = false;
}
},
});
function updateConfirmGate(): void {
panel.setCanConfirm(routeReady && !stale);
}
function markStale(): void {
if (restoring || !routeReady) return;
stale = true;
panel.setStale(true);
updateConfirmGate();
if (currentSectionDetail) renderStationLines(currentSectionDetail);
}
viewer.markers.onChange(markStale);
viewer.markers.onSelectionChange(panel.setSelected);
viewer.markers.onStationSelectionChange((stationId) => {
profilePanel.setSelectedStation(stationId);
syncIrregularSelection(stationId);
});
// 3D 램프 클릭 → 그 측을 상단측(측구 방향)으로 지정하고 세션 보관 + 램프 재렌더.
viewer.markers.onUphillPick((stationId, side) => {
if (!currentSectionDetail) return;
const base = currentSectionDetail.longitudinal.stations.find(
(station) => station.station_id === stationId,
);
let chainage = base?.chainage_m;
if (chainage === undefined) {
const prefix = irregularStationId("");
if (stationId.startsWith(prefix)) {
const id = stationId.slice(prefix.length);
chainage = irregularStations.find((entry) => entry.id === id)?.chainage_m;
}
}
if (chainage === undefined) return;
uphillOverrides.set(uphillKey(chainage), side);
persistUphillOverrides();
renderStationLines(currentSectionDetail);
});
viewer.root.append(panel.viewControls);
function restorePanel(next: RouteLatestResponse): void {
const options = next.route_params?.options ?? {};
panel.restore({
contourInterval: next.surface_params.contour_interval_m,
algorithm: next.route_params?.algorithm as RoutePanelValues["algorithm"] | undefined,
gradeClass: options.grade_class as RoutePanelValues["gradeClass"] | undefined,
paved: options.paved as boolean | undefined,
minCurveRadius: options.min_curve_radius_m as number | undefined,
maxUphillGrade: options.max_uphill_grade as number | undefined,
maxDownhillGrade: options.max_downhill_grade as number | undefined,
minUphillGrade: options.min_uphill_grade as number | undefined,
minDownhillGrade: options.min_downhill_grade as number | undefined,
allowAvoidPassThrough: options.allow_avoid_pass_through as boolean | undefined,
stationInterval: next.route_params?.station_interval_m ?? undefined,
crossSampleInterval: next.route_params?.cross_sample_interval_m ?? undefined,
longSampleInterval: next.route_params?.long_sample_interval_m ?? undefined,
terrainType: options.terrain_type as RoutePanelValues["terrainType"] | undefined,
maxGradePct: next.route_params?.max_grade_pct ?? undefined,
minVerticalRadius: next.route_params?.min_vertical_radius_m ?? undefined,
minTangentLength: next.route_params?.min_tangent_length_m ?? undefined,
startElevationOffset: next.route_params?.start_elevation_offset_m ?? undefined,
endElevationOffset: next.route_params?.end_elevation_offset_m ?? undefined,
});
viewer.markers.setPoints(restorePoints(next));
}
function renderStationLines(detail: SectionDetailResponse): void {
// 확정 때 종단 정본에 병합해 둔 비정규 측점은 그리지 않는다. 화면의 정본은 사이드바 목록이고
// 관을 옮기면 그 목록만 따라오므로, 둘을 겹쳐 그리면 옮기기 전 자리가 그대로 남는다
// (2026-08-02 사용자 보고). 저장분은 진입 시 `restoreSections`가 목록으로 되살린다.
const regular = detail.longitudinal.stations.filter((station) => station.kind !== "irregular");
const injected = interpolateIrregularStations(
regular,
irregularStations,
detail.longitudinal.length_m,
);
// 측점 바 양 끝 램프용 상단측: 사용자 변경분 → solve 자동 판정 순으로 적용.
const withUphill = [...regular, ...injected].map((station) => ({
...station,
uphill_side:
uphillOverrides.get(uphillKey(station.chainage_m)) ?? station.uphill_side ?? null,
}));
viewer.renderStationLines(withUphill, roadWidths[panel.values().gradeClass] / 2);
}
/** 비정규 측점 목록 변경 → 3D·그래프·테이블에 반영(프론트 프리뷰, 백엔드 미전송). */
function applyIrregularStations(stations: IrregularStation[]): void {
// 위치가 바뀌거나 삭제된 비정규 측점의 옛 chainage에 남은 계획고 편집(유령 변화점)을 지운다.
const nextKeys = new Set(stations.map((station) => station.chainage_m.toFixed(3)));
irregularStations
.filter((station) => !nextKeys.has(station.chainage_m.toFixed(3)))
.forEach((station) => profilePanel.resetStationEdit(station.chainage_m));
irregularStations = stations;
if (currentSectionDetail) renderStationLines(currentSectionDetail);
profilePanel.setIrregularStations(stations);
// 좌측 구조물 폼에서 "배관" 항목을 고쳤거나 지웠으면 배수유역도까지 따라가야 한다.
// 관 목록이 그대로면 배수유역도가 아무 일도 하지 않으므로 되먹임 고리는 여기서 끊긴다.
profilePanel.drainage.setPipeChainages(
stations.filter(isPipeStation).map((entry) => entry.chainage_m),
);
}
function renderSections(detail: SectionDetailResponse, routeId?: number): void {
currentSectionDetail = detail;
profilePanel.render(detail, panel.values().stationInterval ?? undefined, routeId);
profilePanel.setStationDisplay(panel.stationDisplayOffset());
profilePanel.setIrregularStations(irregularStations);
renderStationLines(detail);
}
async function restoreSections(routeId: number): Promise<void> {
let detail: SectionDetailResponse;
profilePanel.setLoading("종단면 자료를 불러오는 중…");
try {
// 공유 캐시 — B06이 이미 받아 뒀으면 같은 객체를 재사용해 두 페이지가 항상 같은 값을 본다.
detail = await loadSectionDetail(activeProjectId, routeId);
} catch {
// 종횡단 데이터 자체가 없는 경우(생성 실패·최초 진입)는 빈 안내로 둔다.
currentSectionDetail = null;
profilePanel.setLoading(null);
profilePanel.clear();
viewer.renderStationLines([], 0);
return;
}
profilePanel.setLoading(null);
try {
renderSections(detail, routeId);
// 복귀/최초 진입 시(클라이언트 목록이 비어 있을 때만) 확정된 비정규 측점을 사이드바에 복원한다.
// 재탐색 시엔 새 경로 detail에 비정규 측점이 없어(빈 목록으로 덮이지 않게) 이 가드로 건너뛴다.
if (!irregularStations.length) {
const restored = detail.longitudinal.stations
.filter((station) => station.kind === "irregular")
.map((station) => ({
chainage_m: station.chainage_m,
structure: station.structure ?? "",
}));
if (restored.length) panel.irregularStations.setStations(restored);
}
} catch (error) {
// 렌더 실패를 "데이터 없음"으로 감추면 원인을 알 수 없게 된다. 조회는 성공했으므로
// 빈 안내로 되돌리지 않고 실패 사실을 그대로 드러낸다.
console.error("B05 종단면도 렌더 실패", error);
showToast(
error instanceof Error
? `종단면도 표시 실패: ${error.message}`
: "종단면도 표시에 실패했습니다.",
"error",
);
}
}
function renderLatest(next: RouteLatestResponse): void {
latest = next;
routeReady = Boolean(next.route && next.route_points.length > 1);
stale = false;
panel.setStale(false);
profilePanel.setRoutePolyline(next.route_points ?? []);
if (next.route) {
const stored = next.route.algorithm_params ?? {};
viewer.markers.renderRoute(
next.route_points,
(stored.curve_warning_segments as Array<{
polyline_start_index: number;
polyline_end_index: number;
}>) ?? [],
);
}
updateConfirmGate();
}
async function applyContours(interval: number): Promise<void> {
showLoadingOverlay();
try {
await viewer.reloadContours(interval);
await updateContourInterval(activeProjectId, interval);
// 세션 캐시에도 반영해 다음 진입 시 옛 등고선 간격으로 복원되지 않게 한다.
const cached = readLatestCache();
if (cached) {
writeLatestCache({
...cached,
surface_params: { ...cached.surface_params, contour_interval_m: interval },
});
}
} catch (error) {
showToast(error instanceof Error ? error.message : "등고선 조회에 실패했습니다.", "error");
} finally {
hideLoadingOverlay();
}
}
async function solve(): Promise<void> {
if (!confirmedSurface || !latest) return;
const points = viewer.markers.getPoints();
if (!points.bp || !points.ep) {
showToast("BP와 EP를 지형에 배치하세요.", "error");
return;
}
const values = panel.values();
showLoadingOverlay();
try {
const solved = await solveRoute(activeProjectId, {
filter_key: latest.surface_params.source_filter,
method: latest.surface_params.method,
smooth: latest.surface_params.smooth,
surface_model_id: confirmedSurface.id,
algorithm: values.algorithm,
bp: routePoint(points.bp),
ep: routePoint(points.ep),
cp: points.cp.map(routePoint),
ap: points.ap.map(circlePoint),
fp: points.fp.map(circlePoint),
grade_class: values.gradeClass,
paved: values.paved,
min_curve_radius_m: values.minCurveRadius,
max_uphill_grade: values.maxUphillGrade,
max_downhill_grade: values.maxDownhillGrade,
min_uphill_grade: values.minUphillGrade,
min_downhill_grade: values.minDownhillGrade,
allow_avoid_pass_through: values.allowAvoidPassThrough,
station_interval_m: values.stationInterval,
cross_half_width_m: null,
cross_sample_interval_m: values.crossSampleInterval,
long_sample_interval_m: values.longSampleInterval,
terrain_type: values.terrainType,
max_grade_pct: values.maxGradePct,
min_vertical_radius_m: values.minVerticalRadius,
min_tangent_length_m: values.minTangentLength,
start_elevation_offset_m: values.startElevationOffset,
end_elevation_offset_m: values.endElevationOffset,
});
// 새 경로는 측점 구성이 달라지므로 이전 상단측 변경분을 폐기한다(자동 판정 재사용).
uphillOverrides.clear();
persistUphillOverrides();
renderLatest(await loadLatest(true));
await restoreSections(solved.route_id);
if (solved.cross_section_count === null) {
showToast("경로는 저장되었지만 종횡단 생성에 실패했습니다.", "error");
} else if (solved.grade_summary === null) {
showToast("경로·종횡단은 저장되었지만 계획선 산출에 실패했습니다.", "error");
} else {
showToast("최적 경로 계산이 완료되었습니다.", "success");
}
} catch (error) {
showToast(error instanceof Error ? error.message : "경로 계산에 실패했습니다.", "error");
} finally {
hideLoadingOverlay();
}
}
async function confirm(): Promise<void> {
if (!routeReady || stale) return;
// 관 매설 지점을 B04와 같은 저장소에 남긴다 — 저장 실패가 경로 확정을 막지는 않는다.
await profilePanel.drainage.savePipes().catch(() => 0);
showLoadingOverlay();
try {
// 종단 계획선 편집은 화면에서만 계산해 두었으므로 확정 직전에 영속화한다.
await profilePanel.save();
// 서버가 종단 정본의 계획선을 다시 썼다 — 공유 캐시를 비워 B06이 옛 계획선을 못 보게 한다.
invalidateSectionDetail(activeProjectId);
// 비정규 측점(구조물)이 있으면 확정 시 그 횡단까지 생성하도록 지표 샘플러 입력을 함께 보낸다.
await confirmRoute(activeProjectId, {
filter_key: latest?.surface_params.source_filter,
method: latest?.surface_params.method,
smooth: latest?.surface_params.smooth,
surface_model_id: confirmedSurface?.id,
irregular_stations: irregularStations.map((station) => ({
chainage_m: station.chainage_m,
structure: station.structure,
})),
// 상단측(측구 방향) 사용자 변경분 — 종단 정본에 병합되어 B06이 그대로 소비한다.
uphill_overrides: [...uphillOverrides.entries()].map(([chainage, side]) => ({
chainage_m: Number(chainage),
side,
})),
});
renderLatest(await loadLatest(true));
showToast("경로를 확정했습니다.", "success");
goToWorkflowStage(activeProjectId, WORKFLOW_STEP_ROUTES[3]);
} catch (error) {
showToast(error instanceof Error ? error.message : "경로 확정에 실패했습니다.", "error");
} finally {
hideLoadingOverlay();
}
}
/* ── 진입 로딩 ─────────────────────────────────────────────────────────
* 전부 받아 놓고 한 번에 그리면 몇 초 동안 빈 화면만 보인다. 화면 틀을 먼저 띄우고
* 자료가 끝나는 순서대로 채운다. 3D 지형이 가장 느리므로 맨 마지막에 올리고, 그동안
* 3D 뷰포트에 공통 프로그레스 서클을 띄운다(2026-08-01 사용자 지시). */
const LOAD_STEP_COUNT = 5;
// 3D 뷰포트 정중앙. 하단 종단 패널(z-index 3)보다 아래라 패널에 가려지는 것은 무방하다
// (2026-08-01 사용자 지시).
const progress = createProgressCircle({ label: "화면 틀을 준비하는 중…", overlay: true });
viewer.root.append(progress.root);
let loadedSteps = 0;
function advanceLoading(label: string): void {
loadedSteps += 1;
progress.set(Math.min(1, loadedSteps / LOAD_STEP_COUNT), label);
}
// ① 워크플로우 상태 — 화면 틀(레이아웃·단계바)을 세우는 데 필요한 최소 자료.
const workflowState = await fetchWorkflowState(activeProjectId);
advanceLoading("노선 정보를 불러오는 중…");
const mainContent = document.createElement("div");
mainContent.className = "b05-route__main";
mainContent.append(viewer.root, profilePanel.root);
const layout = createWorkflowLayout({
title: L("B05_Route_Title"),
steps: workflowSteps(),
activeStep: 2,
leftPanel: panel.root,
mainContent,
stages: workflowState.stages,
currentStage: workflowState.current_stage,
routes: WORKFLOW_STEP_ROUTES,
onStepClick: (stepIndex) => goToWorkflowStage(activeProjectId, WORKFLOW_STEP_ROUTES[stepIndex]),
});
layout.root.classList.add("b05-route-layout");
root.replaceChildren(layout.root);
try {
// ② 좌측 폼·노선 설정값 — 도착하는 대로 폼과 3D 마커 복원에 쓴다.
const [latestResponse, sectionContext, configuredRoadWidths] = await Promise.all([
// 세션 캐시 우선(응답속도) — 최초 진입/캐시 미스 시에만 DB(latest)를 읽는다.
loadLatest(),
fetchSectionContext(activeProjectId),
fetchRoadWidths(activeProjectId),
]);
roadWidths = configuredRoadWidths;
panel.restore({
stationInterval: sectionContext.defaults.station_interval_m,
crossSampleInterval: sectionContext.defaults.cross_sample_interval_m,
longSampleInterval: sectionContext.defaults.long_sample_interval_m,
});
// 유토곡선용 설정 — 토량환산계수·운반장비 경계·자연방토 판정 경사를 config_system
// 정의 그대로 받아 B06과 같은 계산을 태운다(프론트 사본 금지).
profilePanel.setEarthworkContext({
conversion: sectionContext.earthwork_conversion,
haulLimits: sectionContext.haul_equipment_limits,
naturalSpoilMinSlope: sectionContext.natural_spoil_min_ground_slope ?? undefined,
});
restorePanel(latestResponse);
renderLatest(latestResponse);
latest = latestResponse;
advanceLoading("확정 지표면 모델을 확인하는 중…");
// ③ 확정 지표면 모델 목록.
const models = await listSurfaceModels(activeProjectId);
confirmedSurface = models.models.find((model) => model.status === "CONFIRMED") ?? null;
advanceLoading("종단면 자료를 불러오는 중…");
// ④ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다.
if (latestResponse.route) await restoreSections(latestResponse.route.id);
advanceLoading("3D 지형을 불러오는 중…");
// ⑤ 3D 지형 — 가장 무거우므로 맨 마지막.
if (!confirmedSurface) {
showToast("확정된 지표면 모델이 없습니다.", "error");
} else {
// 지형 가장자리만 필요하다 — 포인트클라우드 전체(수십 MB)는 받지 않는다.
const confirmed = await fetchConfirmedSurface(activeProjectId);
if (!confirmed.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다.");
await viewer.loadSurface(
activeProjectId,
confirmedSurface.id,
latestResponse.surface_params.method,
latestResponse.surface_params.smooth,
latestResponse.surface_params.contour_interval_m,
toBounds(confirmed.bounds),
);
// 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다.
renderLatest(latestResponse);
if (currentSectionDetail) renderStationLines(currentSectionDetail);
}
advanceLoading("");
} catch (error) {
showToast(error instanceof Error ? error.message : "화면을 불러오지 못했습니다.", "error");
} finally {
progress.remove();
restoring = false;
}
}