1032 lines
45 KiB
TypeScript
1032 lines
45 KiB
TypeScript
import { CURRENT_PROJECT_ID_KEY, ROUTES } 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 {
|
|
clearRouteLatestCache,
|
|
confirmRoute,
|
|
fetchLatestRoute,
|
|
readRouteLatestCache,
|
|
resetRouteDesign,
|
|
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 { leaveForDashboard } from "../A00_Common/b_missing_data_guard";
|
|
import { navigateTo } from "../A00_Common/router";
|
|
import { createSelectionSync } from "./B05_Profile_UI_Selection";
|
|
import { createRouteViewer } from "./B05_Profile_UI_Viewer";
|
|
import {
|
|
irregularLabel,
|
|
irregularStationId,
|
|
isPipeStation,
|
|
structureLabel,
|
|
type IrregularStation,
|
|
} from "./B05_Profile_UI_IrregularStations";
|
|
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
|
import { pickPipeDiameter, PIPE_DEFAULT_TYPE } from "@config/config_frontend";
|
|
import {
|
|
fetchSectionContext,
|
|
type SectionDetailResponse,
|
|
type SectionStation,
|
|
} from "../B06_Section/B06_Section_Api_Fetch";
|
|
import {
|
|
invalidateSectionDetail,
|
|
loadSectionDetail,
|
|
} from "../B06_Section/B06_Section_Section_Store";
|
|
import {
|
|
fetchStructures,
|
|
fetchStructureTypes,
|
|
migrateLegacyStations,
|
|
saveStructures,
|
|
StructureConflictError,
|
|
type StructureInstance,
|
|
} from "./B05_Profile_Api_Structures";
|
|
import "./B05_Profile_UI_Style.css";
|
|
import "./B05_Profile_UI_Style_Structures.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,
|
|
// 표고를 모르면 모르는 채로 넘긴다 — 0으로 눕히면 마커가 지형 한참 아래 평면에 깔린다.
|
|
z: point.z ?? null,
|
|
...(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 {
|
|
// 서버 스키마는 표고를 빼면 "모름"으로 받는다 — null은 undefined로 바꿔 보낸다.
|
|
return { x: point.x, y: point.y, z: point.z ?? undefined };
|
|
}
|
|
|
|
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();
|
|
// 구 우클릭 메뉴의 자유 텍스트 구조물 → 레지스트리 타입(2026-08-17 이관 매핑과 동일).
|
|
const LEGACY_TYPE_IDS: Record<string, string> = {
|
|
기성막이: "revetment",
|
|
대피로: "refuge",
|
|
기타: "etc",
|
|
};
|
|
const profilePanel = createRouteProfilePanel(
|
|
activeProjectId,
|
|
(stationId) => {
|
|
viewer.markers.selectStation(stationId);
|
|
syncIrregularSelection(stationId);
|
|
},
|
|
// [초기선 복원] 시 그래프의 배관 투영도 지운다(관 정본은 배수유역 초기화가 맡는다).
|
|
() => clearProjectedStations(),
|
|
{
|
|
// 관 매설 목록 → 그래프 배관 측점선·통합 목록을 한 방향으로 맞춘다.
|
|
// 정본은 배수유역도의 관 지점이며, 화면 목록은 그것을 실체화한 것이다.
|
|
onPipesChanged: (pipes) => {
|
|
// 횡단배수는 계획선을 그 지점에 물리는 시설이라 그래프 세로 점선·계획고 틸팅
|
|
// 버튼이 필요하다(2026-08-17 사용자 지시). 알약 레인은 종류·선택을 맡는다.
|
|
pipeStations = pipesToStations(pipes);
|
|
syncCrossDrainStations();
|
|
pipeMarks = pipesToMarks(pipes);
|
|
syncGraphStructures();
|
|
panel.structures.setPipeFacilities(
|
|
pipes.map((pipe) => ({
|
|
chainage_m: pipe.chainage_m,
|
|
facility: pipe.facility,
|
|
start_m: pipe.start_m,
|
|
end_m: pipe.end_m,
|
|
source: pipe.source,
|
|
options: pipe.options,
|
|
})),
|
|
);
|
|
},
|
|
// 그래프 측점선은 이제 배관(계곡 통과 시설) 투영뿐이다 — 수동 구조물은
|
|
// 서클마크·구조물 배치 목록이 담당한다(2026-08-17 컨테이너 병합).
|
|
onStructureMove: (from, to, station) => {
|
|
if (isPipeStation(station)) profilePanel.drainage.movePipe(from, to);
|
|
},
|
|
onStructureRemove: (station) => {
|
|
if (isPipeStation(station)) profilePanel.drainage.removePipe(station.chainage_m);
|
|
},
|
|
onPipeAdd: (chainage) => profilePanel.drainage.addPipe(chainage),
|
|
// 우클릭 메뉴의 구 항목(기성막이·대피로·기타) — 구조물 정본 타입으로 넣는다.
|
|
onStructureAdd: (chainage, type) =>
|
|
panel.structures.addAt(chainage, LEGACY_TYPE_IDS[type] ?? "etc"),
|
|
onIrregularSelect: (station) => syncIrregularSelection(irregularStationId(station.id)),
|
|
// 배수유역도에서 유역을 고르면 그 관의 구조물 측점을 그래프·3D·사이드 패널에서도 고른다.
|
|
onBasinSelected: (chainageM) => selectStationOfPipe(chainageM),
|
|
// 관 마커 선택(유역 없는 관 포함)도 같은 경로로 전 화면을 맞춘다(2026-08-17).
|
|
onPipeSelected: (chainageM) => selectStationOfPipe(chainageM),
|
|
// 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설
|
|
// (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17).
|
|
onStructureSelect: (structureId) => {
|
|
const chainage = pipeMarkChainage(structureId);
|
|
if (chainage !== null) {
|
|
panel.structures.selectPipeByChainage(chainage);
|
|
selectStationOfPipe(chainage);
|
|
return;
|
|
}
|
|
panel.structures.selectById(structureId);
|
|
},
|
|
onStructureMarkMove: (structureId, toChainage) => {
|
|
const chainage = pipeMarkChainage(structureId);
|
|
if (chainage !== null) {
|
|
profilePanel.drainage.movePipe(chainage, toChainage);
|
|
return;
|
|
}
|
|
panel.structures.moveById(structureId, toChainage);
|
|
},
|
|
onStructureTypeAdd: (chainage, typeId) => panel.structures.addAt(chainage, typeId),
|
|
},
|
|
);
|
|
|
|
/**
|
|
* 그래프·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 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),
|
|
// 통합 「구조물 배치」 목록의 계곡 통과 시설 항목을 강조한다(2026-08-17 통합).
|
|
selectSidebar: (chainageM) => panel.structures.selectPipeByChainage(chainageM),
|
|
selectBasin: (chainageM) => profilePanel.drainage.selectBasinByChainage(chainageM),
|
|
// 배관이면 배수유역 마커 선택 + 부속 옵션 폼까지 연다(2026-08-17 전역 선택 동기화).
|
|
selectPipeForm: (chainageM) => profilePanel.drainage.selectPipeAtChainage(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(),
|
|
onTempSave: () => void tempSave(),
|
|
onGoCross: () => navigateTo(ROUTES.B06_SECTION),
|
|
onReset: () => void resetDesign(),
|
|
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,
|
|
onStationDisplayChange: (offset) => profilePanel.setStationDisplay(offset),
|
|
onStructuresChange: (next) => applyStructures(next),
|
|
// 계곡 통과 시설(A군) — 관 지점 정본(배수유역 패널) 경유(2026-08-17 통합).
|
|
onPipeFacilityAdd: (chainage, attributes) =>
|
|
profilePanel.drainage.addPipe(chainage, attributes),
|
|
onPipeFacilityUpdate: (from, to, attributes) =>
|
|
profilePanel.drainage.updatePipeFacility(from, to, attributes),
|
|
onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage),
|
|
onPipeFacilitySelect: (chainage) => {
|
|
if (selectionSyncing) return;
|
|
selectionSyncing = true;
|
|
try {
|
|
const station = irregularStations.find(
|
|
(entry) => Math.abs(entry.chainage_m - chainage) < 0.05,
|
|
);
|
|
const id = station ? irregularStationId(station.id) : null;
|
|
viewer.markers.selectStation(id);
|
|
profilePanel.setSelectedStation(id);
|
|
// 통합 목록에서 배관 항목을 고르면 배수유역도의 그 유역도 함께 강조한다.
|
|
syncBasinHighlight(id);
|
|
// 배수유역 마커 선택 + 부속 옵션 폼도 연다(2026-08-17 전역 선택 동기화).
|
|
profilePanel.drainage.selectPipeAtChainage(chainage);
|
|
} finally {
|
|
selectionSyncing = false;
|
|
}
|
|
},
|
|
onStructureSelect: (structure) => {
|
|
if (selectionSyncing) return;
|
|
selectionSyncing = true;
|
|
try {
|
|
profilePanel.setSelectedStructure(structure?.structure_id ?? null);
|
|
} finally {
|
|
selectionSyncing = false;
|
|
}
|
|
},
|
|
});
|
|
|
|
/** 입력·마커 변경 시 측점 라인만 다시 그린다 — 확정 게이트는 폐지(B06 통합 확정). */
|
|
function markStale(): void {
|
|
if (restoring || !routeReady) return;
|
|
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 라벨용(배관은 관종·관경까지). */
|
|
const FACILITY_NAMES: Record<PipeFacility, string> = {
|
|
pipe: "배수관",
|
|
box_culvert: "BOX암거",
|
|
ford_pavement: "물넘이포장",
|
|
ford_bridge: "세월교",
|
|
};
|
|
|
|
/** 관 매설 목록을 그래프·3D용 측점 목록으로 실체화한다(구 비정규 측점 투영의 후신).
|
|
* 배관은 유효직경으로 관경을 자동 지정하고, 다른 시설은 종류 이름을 라벨로 쓴다. */
|
|
function pipesToStations(
|
|
pipes: Array<{
|
|
chainage_m: number;
|
|
effective_diameter_mm: number | null;
|
|
facility: PipeFacility;
|
|
}>,
|
|
): IrregularStation[] {
|
|
const interval = panel.values().stationInterval || 20;
|
|
return pipes.map((pipe) => {
|
|
const station = Math.floor(pipe.chainage_m / interval);
|
|
const remainder = pipe.chainage_m - station * interval;
|
|
const label =
|
|
pipe.facility === "pipe"
|
|
? structureLabel({
|
|
structureType: "배관",
|
|
pipeType: PIPE_DEFAULT_TYPE,
|
|
diameterMm: pickPipeDiameter(PIPE_DEFAULT_TYPE, pipe.effective_diameter_mm),
|
|
})
|
|
: FACILITY_NAMES[pipe.facility];
|
|
return {
|
|
id: `pipe-${pipe.chainage_m.toFixed(2)}`,
|
|
station,
|
|
remainder,
|
|
chainage_m: pipe.chainage_m,
|
|
structure: label,
|
|
structureType: "배관",
|
|
origin: "pipe",
|
|
};
|
|
});
|
|
}
|
|
|
|
/** [초기선 복원] 등에서 그래프의 배관 투영만 지운다 — 관 정본은 건드리지 않는다. */
|
|
function clearProjectedStations(): void {
|
|
pipeStations = [];
|
|
irregularStations = [];
|
|
if (currentSectionDetail) renderStationLines(currentSectionDetail);
|
|
profilePanel.setIrregularStations([]);
|
|
}
|
|
|
|
/** 비정규 측점 목록 변경 → 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),
|
|
);
|
|
}
|
|
|
|
/* ── 구조물 정본(structures.json) ────────────────────────────────────
|
|
* 사이드 목록이 바뀌면 곧바로 서버 정본에 저장한다 — 화면에만 남겨 두면 새로고침에
|
|
* 사라지고, 다른 창과도 어긋난다. 판번호가 밀리면(다른 창이 먼저 저장) 최신본을
|
|
* 받아 화면을 맞추고 사용자에게 알린다. */
|
|
let structureRevision = 0;
|
|
let structureSaving: Promise<void> = Promise.resolve();
|
|
|
|
/* ── 횡단배수(A군) 측점 세로선 ──────────────────────────────────────────
|
|
* 횡단배수 시설은 계획선을 그 지점에 물리므로 그래프에 세로 점선과 계획고 틸팅
|
|
* 버튼(▲▼)이 있어야 한다(2026-08-17 사용자 지시). 대상은 관 정본(배수관·BOX암거·
|
|
* 물넘이·세월교)에 더해 수동 A군(노출형 횡단수로·개거)까지다. */
|
|
let pipeStations: IrregularStation[] = [];
|
|
let structureTypeMap = new Map<string, { group: string; name: string }>();
|
|
|
|
/** A군 수동 구조물을 그래프 측점 목록으로 투영한다(관 정본 항목은 pipeStations 몫). */
|
|
function crossDrainStationsOf(structures: StructureInstance[]): IrregularStation[] {
|
|
const interval = panel.values().stationInterval || 20;
|
|
return structures
|
|
.filter((structure) => structureTypeMap.get(structure.type_id)?.group === "A")
|
|
.map((structure) => {
|
|
const chainage = structure.chainage_m ?? structure.start_m ?? 0;
|
|
const station = Math.floor(chainage / interval);
|
|
return {
|
|
id: structure.structure_id ?? `structure-${chainage.toFixed(2)}`,
|
|
station,
|
|
remainder: chainage - station * interval,
|
|
chainage_m: chainage,
|
|
structure: structureTypeMap.get(structure.type_id)?.name ?? structure.type_id,
|
|
};
|
|
});
|
|
}
|
|
|
|
/** 관 정본 + A군 수동 구조물을 합쳐 그래프·3D 측점 목록을 맞춘다. */
|
|
function syncCrossDrainStations(): void {
|
|
applyIrregularStations([...pipeStations, ...crossDrainStationsOf(ownStructures)]);
|
|
}
|
|
|
|
/** 알약 id가 계곡 통과 시설(관 정본)이면 그 누가거리, 아니면 null. */
|
|
function pipeMarkChainage(structureId: string | null): number | null {
|
|
if (!structureId?.startsWith("pipe-")) return null;
|
|
const value = Number(structureId.slice("pipe-".length));
|
|
return Number.isFinite(value) ? value : null;
|
|
}
|
|
|
|
/** 그래프 알약 레인에 올릴 목록 — 구조물 정본 + 계곡 통과 시설(관 정본)을 합친다.
|
|
* 자동 배수관도 세로 점선이 아니라 같은 알약으로 나온다(2026-08-17 사용자 지시 1). */
|
|
let ownStructures: StructureInstance[] = [];
|
|
let pipeMarks: StructureInstance[] = [];
|
|
|
|
function syncGraphStructures(): void {
|
|
profilePanel.setStructures([...ownStructures, ...pipeMarks]);
|
|
}
|
|
|
|
/** 관 지점을 알약 레인용 가상 구조물로 만든다(정본은 pipe_points, 저장하지 않는다). */
|
|
function pipesToMarks(
|
|
pipes: Array<{
|
|
chainage_m: number;
|
|
facility: PipeFacility;
|
|
options?: Record<string, string | number>;
|
|
}>,
|
|
): StructureInstance[] {
|
|
return pipes.map((pipe) => ({
|
|
structure_id: `pipe-${pipe.chainage_m.toFixed(2)}`,
|
|
type_id: pipe.facility,
|
|
placement: "point",
|
|
chainage_m: pipe.chainage_m,
|
|
start_m: null,
|
|
end_m: null,
|
|
side: "cross",
|
|
offset_m: 0,
|
|
options: pipe.options ?? {},
|
|
memo: "",
|
|
placement_source: "automatic",
|
|
status: "draft",
|
|
revision: 0,
|
|
geometry: null,
|
|
}));
|
|
}
|
|
|
|
function applyStructures(next: StructureInstance[]): void {
|
|
ownStructures = next;
|
|
syncGraphStructures();
|
|
syncCrossDrainStations();
|
|
// 저장 요청이 겹치면 판번호가 어긋나므로 앞의 저장이 끝난 뒤에 보낸다.
|
|
structureSaving = structureSaving.then(() => persistStructures(next));
|
|
}
|
|
|
|
/** 서버 정본을 다시 받아 화면(사이드 목록·그래프 마크)을 그 상태로 맞춘다. */
|
|
async function refreshStructuresFromServer(): Promise<boolean> {
|
|
const stored = await fetchStructures(activeProjectId).catch(() => null);
|
|
if (!stored) return false;
|
|
structureRevision = stored.revision;
|
|
panel.structures.setStructures(stored.structures);
|
|
ownStructures = stored.structures;
|
|
syncGraphStructures();
|
|
syncCrossDrainStations();
|
|
return true;
|
|
}
|
|
|
|
async function persistStructures(next: StructureInstance[]): Promise<void> {
|
|
if (restoring) return;
|
|
try {
|
|
const saved = await saveStructures(activeProjectId, structureRevision, next);
|
|
structureRevision = saved.revision;
|
|
// 서버가 새 항목에 식별자를 붙이므로 그 결과로 화면 목록을 맞춘다.
|
|
await refreshStructuresFromServer();
|
|
// 구조물이 바뀌면 B06 이후를 다시 돌려야 한다. 그 표시를 서버가 못 남겼다면
|
|
// 화면상 "완료"인 뒤 단계가 옛 구조물로 만든 결과라는 뜻이라 사용자가 알아야 한다.
|
|
if (saved.needs_downstream_invalidation && !saved.invalidated_downstream) {
|
|
showToast(
|
|
"구조물은 저장되었지만 이후 단계(횡단·수량) 재작업 표시에 실패했습니다. " +
|
|
"B06을 다시 실행해 주세요.",
|
|
"error",
|
|
);
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof StructureConflictError) {
|
|
await refreshStructuresFromServer();
|
|
showToast("다른 창에서 구조물이 먼저 저장되어 최신 내용으로 되돌렸습니다.", "error");
|
|
return;
|
|
}
|
|
// 저장이 거절되면 화면에만 남은 항목은 식별자가 없어 고치지도 지우지도 못한다.
|
|
// 서버 정본으로 되돌려 화면과 정본을 다시 일치시킨다(2026-08-16 크로스체크 지적 1).
|
|
await refreshStructuresFromServer();
|
|
showToast(error instanceof Error ? error.message : "구조물 저장에 실패했습니다.", "error");
|
|
}
|
|
}
|
|
|
|
/** 진입·새로고침 때 타입 레지스트리와 구조물 정본을 받아 화면에 채운다. */
|
|
async function loadStructures(): Promise<void> {
|
|
try {
|
|
const [types, stored] = await Promise.all([
|
|
fetchStructureTypes(),
|
|
fetchStructures(activeProjectId),
|
|
]);
|
|
panel.structures.setTypes(types);
|
|
profilePanel.setStructureTypes(types);
|
|
// A군 판정에 쓸 타입 정보 — 횡단배수만 그래프 세로선·틸팅 대상이다.
|
|
structureTypeMap = new Map(
|
|
types.map((type) => [type.type_id, { group: type.group, name: type.name }]),
|
|
);
|
|
structureRevision = stored.revision;
|
|
panel.structures.setStructures(stored.structures);
|
|
ownStructures = stored.structures;
|
|
syncGraphStructures();
|
|
syncCrossDrainStations();
|
|
} catch (error) {
|
|
showToast(
|
|
error instanceof Error ? error.message : "구조물 정보를 불러오지 못했습니다.",
|
|
"error",
|
|
);
|
|
}
|
|
}
|
|
|
|
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);
|
|
// 구 확정분에 남은 비정규 측점(자유 텍스트)을 구조물 정본으로 1회 이관한다 —
|
|
// 배관은 관 지점 정본이 복원하므로 서버가 걸러내고, 멱등이라 재진입에도 안전하다
|
|
// (2026-08-17 컨테이너 병합 3단계).
|
|
const legacy = detail.longitudinal.stations
|
|
.filter((station) => station.kind === "irregular")
|
|
.map((station) => ({
|
|
chainage_m: station.chainage_m,
|
|
structure: station.structure ?? "",
|
|
}));
|
|
if (legacy.length) {
|
|
void migrateLegacyStations(activeProjectId, legacy)
|
|
.then((result) => {
|
|
if (result.migrated > 0) {
|
|
showToast(`구 구조물 측점 ${result.migrated}건을 구조물 목록으로 옮겼습니다.`);
|
|
return refreshStructuresFromServer().then(() => undefined);
|
|
}
|
|
return undefined;
|
|
})
|
|
.catch(() => {
|
|
/* 이관 실패는 치명적이지 않다 — 다음 진입에서 다시 시도된다. */
|
|
});
|
|
}
|
|
} 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);
|
|
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;
|
|
}>) ?? [],
|
|
);
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
/** [임시저장] — 현재 편집을 영구저장소에 남기되 워크플로 단계·페이지는 그대로 둔다.
|
|
* 종·횡 통합 확정은 B06 [확정]이 담당한다(2026-08-08 워크플로우 재정의). */
|
|
async function tempSave(): Promise<void> {
|
|
if (!routeReady) 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,
|
|
})),
|
|
},
|
|
false,
|
|
);
|
|
renderLatest(await loadLatest(true));
|
|
showToast(L("B05_Route_TempSave_Success"), "success");
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : L("B05_Route_TempSave_Failed"), "error");
|
|
} finally {
|
|
hideLoadingOverlay();
|
|
}
|
|
}
|
|
|
|
/** [초기화] — 사용자 편집 전부 폐기, 계획노선 CSV 기본값으로 B05·B06 재계산 후 재진입. */
|
|
async function resetDesign(): Promise<void> {
|
|
if (!window.confirm(L("B05_Route_Reset_Confirm"))) return;
|
|
showLoadingOverlay();
|
|
try {
|
|
await resetRouteDesign(activeProjectId);
|
|
// 옛 경로 기준 캐시를 전부 비우고 페이지를 새로 그린다 — 초기 계산본이 정본이 된다.
|
|
clearRouteLatestCache(activeProjectId);
|
|
invalidateSectionDetail(activeProjectId);
|
|
showToast(L("B05_Route_Reset_Success"), "success");
|
|
navigateTo(ROUTES.B05_PROFILE);
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : L("B05_Route_Reset_Failed"), "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) {
|
|
// 새 자료가 올라와 옛 결과가 지워진 상태 — 여기서 보여 줄 게 없다.
|
|
leaveForDashboard();
|
|
return;
|
|
} 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);
|
|
}
|
|
// 구조물 타입 레지스트리·정본 — 노선이 없어도 목록은 보여 준다(추가는 노선 이후).
|
|
await loadStructures();
|
|
advanceLoading("");
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : "화면을 불러오지 못했습니다.", "error");
|
|
} finally {
|
|
progress.remove();
|
|
restoring = false;
|
|
}
|
|
}
|