좌측 「계획노선」 섹션의 [계획노선 편집] 로 큰 모달을 엶(PLAN 0-2). - 등고선 도엽 위에 예상노선(점선)·계획노선(실선)을 함께 그림. 지도 그리기는 배수유역도와 같은 도구(`B04_PreProcess_UI_MapRender`) 재사용. - 노드 끌어 옮기기 · 선 두 번 클릭으로 노드 끼우기 · 오른쪽 클릭으로 지우기, 배경 끌기로 화면 이동, 휠로 확대. - 편집 중에는 계산이 나가지 않음. [확인]에서만 서버가 배수유역부터 재계산하며 그동안 화면을 덮는 안내를 띄움. 끝나면 세션 초안·조회 캐시를 비우고 페이지를 다시 세움. - [예상노선으로] 는 수정본을 지우고 같은 재계산(노선 초기화). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
753 lines
38 KiB
TypeScript
753 lines
38 KiB
TypeScript
import { dropStationsNear } from "./B05_Profile_Util_Station";
|
|
import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
|
|
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
|
import { 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,
|
|
type SurfaceConfirmedResponse,
|
|
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
|
import {
|
|
fetchLatestRoute,
|
|
readRouteLatestCache,
|
|
writeRouteLatestCache,
|
|
updateContourInterval,
|
|
type RouteLatestResponse,
|
|
} from "./B05_Profile_Api_Fetch";
|
|
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 { openRouteEditModal } from "./B05_Profile_UI_RouteEdit";
|
|
import { createSelectionSync } from "./B05_Profile_UI_Selection";
|
|
import {
|
|
restoreStructurePick,
|
|
wireStructurePick,
|
|
writeStructurePick,
|
|
} from "./B05_Profile_UI_Structure_Pick_Session";
|
|
import { createStructuresBridge } from "./B05_Profile_UI_Page_Structures";
|
|
import { createRouteViewer } from "./B05_Profile_UI_Viewer";
|
|
import { irregularStationId, isPipeStation } from "./B05_Profile_UI_IrregularStations";
|
|
import {
|
|
rememberRockBoundaryDefault,
|
|
rememberStandardDefaults,
|
|
} from "../B06_Section/B06_Section_UI_Standard_Panel";
|
|
import {
|
|
fetchSectionContext,
|
|
type SectionDetailResponse,
|
|
} from "../B06_Section/B06_Section_Api_Fetch";
|
|
import { loadSectionDetail } from "../B06_Section/B06_Section_Section_Store";
|
|
import { migrateLegacyStations } from "./B05_Profile_Api_Structures";
|
|
import { refreshCorridor, saveCorridorIfDirty } from "./B05_Profile_UI_Corridor";
|
|
import {
|
|
resetDesignAction,
|
|
solveRouteAction,
|
|
tempSaveAction,
|
|
type PageActionContext,
|
|
} from "./B05_Profile_UI_Page_Actions";
|
|
import "./B05_Profile_UI_Style.css";
|
|
// 700줄 제한으로 잘라낸 조각 — 본체 **다음에** 불러야 캐스케이드 순서가 같다(2026-09-04).
|
|
import "./B05_Profile_UI_Style_Table.css";
|
|
import "./B05_Profile_UI_Style_Drainage.css";
|
|
import "./B05_Profile_UI_Style_Structures.css";
|
|
import {
|
|
DEFAULT_ROAD_WIDTHS,
|
|
fetchRoadWidths,
|
|
interpolateIrregularStations,
|
|
loadUphillOverrides,
|
|
restorePoints,
|
|
saveUphillOverrides,
|
|
toBounds,
|
|
toGradeClass,
|
|
} from "./B05_Profile_UI_Page_Helpers";
|
|
|
|
function L(key: keyof typeof ui_locales): string {
|
|
return ui_locales[key][currentLanguageIndex];
|
|
}
|
|
|
|
/** 구 구조물 측점 이관을 이미 시도한 노선 — 탭 수명 동안 유지한다. */
|
|
const migratedRoutes = new Set<string>();
|
|
|
|
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);
|
|
},
|
|
// [초기선 복원] 시 그래프의 배관 투영도 지운다(관 정본은 배수유역 초기화가 맡는다).
|
|
() => bridge.clearProjectedStations(),
|
|
{
|
|
// 계획선 편집 프리뷰가 횡단 설계선을 갱신해도 3D는 **자동으로 따라오지 않는다**
|
|
// (2026-09-01 사용자 지시). 디바운스 자동 갱신조차 편집 중 조작을 무겁게 만들어,
|
|
// 갱신은 상단 [3D 업데이트] 버튼으로 모았다. 여기서는 밀린 편집이 있다는 표시만 한다.
|
|
onCrossDesignsUpdated: () => panel.setCorridorPending(true),
|
|
// 관 매설 목록 → 그래프 배관 측점선·통합 목록을 한 방향으로 맞춘다.
|
|
// 정본은 배수유역도의 관 지점이며, 화면 목록은 그것을 실체화한 것이다.
|
|
onPipesChanged: (pipes) => {
|
|
// 횡단배수는 계획선을 그 지점에 물리는 시설이라 그래프 세로 점선·계획고 틸팅
|
|
// 버튼이 필요하다(2026-08-17 사용자 지시). 알약 레인은 종류·선택을 맡는다.
|
|
bridge.setPipes(pipes);
|
|
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,
|
|
design_flow_m3s: pipe.design_flow_m3s,
|
|
options: pipe.options,
|
|
})),
|
|
);
|
|
restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다.
|
|
},
|
|
// 그래프 측점선은 이제 배관(계곡 통과 시설) 투영뿐이다 — 수동 구조물은
|
|
// 서클마크·구조물 배치 목록이 담당한다(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);
|
|
},
|
|
// 우클릭 [배관 추가] — 즉시 추가 대신 사이드 폼에 종류·측점을 실어 주고,
|
|
// A군 임시 배치(세부유역 미리보기)로 이어진다. [추가]로 확정(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) => selectStationOfPipe(chainageM),
|
|
// 관 마커 선택(유역 없는 관 포함)도 같은 경로로 전 화면을 맞춘다(2026-08-17).
|
|
onPipeSelected: (chainageM) => selectStationOfPipe(chainageM),
|
|
// 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설
|
|
// (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17).
|
|
onStructureSelect: (structureId) => {
|
|
const chainage = bridge.pipeMarkChainage(structureId);
|
|
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),
|
|
},
|
|
);
|
|
|
|
/**
|
|
* 그래프·3D에서 비정규 측점을 고르면 사이드바 입력 폼에 로드해 수정/삭제할 수 있게 한다.
|
|
* 선택은 3D·그래프·사이드바가 서로를 갱신하므로, `selectionSyncing` 가드로 재진입을 막아
|
|
* 무한 재귀(스택 오버플로우·프리즈)를 방지한다.
|
|
*/
|
|
/** 확정 지표면 — 모델 id 와 3D 로딩에 필요한 범위만 들고 있는다(2026-09-06 호출 정리).
|
|
* 예전에는 목록(`listSurfaceModels`)으로 id 를 찾고 3D 단계에서 `surface/confirmed`
|
|
* 를 또 불러 요청이 두 번 나갔다. 확정 응답 하나에 둘 다 들어 있다. */
|
|
let confirmedSurface: SurfaceConfirmedResponse | null = null;
|
|
let latest: RouteLatestResponse | null = null;
|
|
let roadWidths = DEFAULT_ROAD_WIDTHS;
|
|
let currentSectionDetail: SectionDetailResponse | null = null;
|
|
/** [예상형상] 표시 상태 — 측점 바를 계획고 위로 올릴지 판단한다(패널 기본 ON). */
|
|
let corridorVisible = true;
|
|
let routeReady = false;
|
|
let restoring = true;
|
|
// 선택 동기화 재진입 가드(3D↔그래프↔사이드바 상호 갱신의 무한 재귀 차단).
|
|
let selectionSyncing = false;
|
|
|
|
/* ── 측점 상단측(=측구 방향) 사용자 변경분 ─────────────────────────────
|
|
* solve가 자동 판정한 uphill_side를 3D 램프 클릭으로 바꾼 값. 세션에 보관했다가
|
|
* 경로 확정 시 uphill_overrides로 백엔드/DB(종단 정본)에 병합한다. */
|
|
const uphillOverrides = loadUphillOverrides(activeProjectId);
|
|
const uphillKey = (chainage: number): string => chainage.toFixed(3);
|
|
|
|
const { syncIrregularSelection, syncBasinHighlight, selectStationOfPipe } = createSelectionSync({
|
|
stations: () => bridge.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),
|
|
selectStructure3D: (chainageM) => viewer.structurePick.selectAtChainage(chainageM),
|
|
});
|
|
|
|
/** 세션에 남은 구조물 선택을 되살린다 — B06에서 고른 것도 그대로 이어 받는다
|
|
* (2026-09-04). 이미 무언가 골라져 있으면 건드리지 않는다. */
|
|
function restorePick(): void {
|
|
if (panel.structures.hasSelection()) return;
|
|
const pick = viewer.structurePick;
|
|
restoreStructurePick(pick, activeProjectId, panel.structures, selectStationOfPipe);
|
|
}
|
|
|
|
function persistUphillOverrides(): void {
|
|
saveUphillOverrides(activeProjectId, uphillOverrides);
|
|
}
|
|
|
|
/* ── 최신 경로/설정값 세션 캐시 ────────────────────────────────────────
|
|
* 확정 이력이 있으면 매 진입마다 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 latestSignature = (value: RouteLatestResponse): string =>
|
|
[
|
|
value.route?.id ?? "",
|
|
value.route?.surface_model_id ?? "",
|
|
value.surface_params.source_filter,
|
|
value.surface_params.method,
|
|
String(value.surface_params.smooth),
|
|
].join("|");
|
|
|
|
/**
|
|
* 캐시로 먼저 그린 뒤 **뒤에서** 신선도를 확인한다(2026-09-06 캐시·세션 일원화).
|
|
*
|
|
* 예전에는 진입 때마다 `loadLatest(true)`로 DB를 다시 읽었다 — 다른 탭의 재업로드로
|
|
* 옛 노선번호가 남을까 봐 넣은 안전장치인데, 그 탓에 B05↔B06을 오갈 때마다 화면이
|
|
* 처음부터 다시 섰다. 이제 서명(노선번호·지표면)이 달라졌을 때만 다시 그린다.
|
|
*/
|
|
async function verifyLatestFreshness(shown: RouteLatestResponse): Promise<void> {
|
|
try {
|
|
const fresh = await fetchLatestRoute(activeProjectId);
|
|
writeLatestCache(fresh);
|
|
if (latestSignature(fresh) === latestSignature(shown)) return;
|
|
renderLatest(fresh);
|
|
if (fresh.route?.id) await restoreSections(fresh.route.id);
|
|
} catch {
|
|
/* 확인 실패는 조용히 넘긴다 — 화면은 캐시로 이미 서 있다. */
|
|
}
|
|
}
|
|
|
|
const panel = createRoutePanel({
|
|
onSolve: () => void solveRouteAction(actionContext),
|
|
// 계획노선 편집 — 모달 [확인]에서 서버가 배수유역부터 다시 계산하므로, 끝나면
|
|
// 옛 노선 기준 캐시를 버리고 페이지를 새로 세운다([초기화]와 같은 뒷정리).
|
|
onEditPlannedRoute: () =>
|
|
void openRouteEditModal(activeProjectId, () => {
|
|
navigateTo(ROUTES.B05_PROFILE);
|
|
}),
|
|
onTempSave: () => void tempSaveAction(actionContext),
|
|
onGoCross: () => {
|
|
// 페이지 이동 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다.
|
|
if (latest?.route?.id) void saveCorridorIfDirty(activeProjectId, latest.route.id);
|
|
navigateTo(ROUTES.B06_SECTION);
|
|
},
|
|
onReset: () => void resetDesignAction(actionContext),
|
|
onContourApply: (interval) => applyContours(interval),
|
|
onSurfaceVisible: viewer.setSurfaceVisible,
|
|
onCorridorVisible: (visible) => {
|
|
corridorVisible = visible;
|
|
viewer.setCorridorVisible(visible);
|
|
// 측점 바 기준면이 바뀐다(예상형상 위 ↔ 원지반 위) — 다시 그린다.
|
|
if (currentSectionDetail) renderStationLines(currentSectionDetail);
|
|
},
|
|
onContoursVisible: viewer.setContoursVisible,
|
|
onAxesVisible: viewer.setAxesVisible,
|
|
onStationLinesVisible: viewer.setStationLinesVisible,
|
|
onStationLabelsVisible: viewer.setStationLabelsVisible,
|
|
onSurfaceGrayscale: viewer.setSurfaceGrayscale,
|
|
onView: viewer.setView,
|
|
onResetView: () => viewer.setView("top"),
|
|
onProjection: viewer.setProjection,
|
|
// [3D 업데이트](2026-09-01) — 밀린 계획선 편집을 예상형상·측점선에 한 번에 반영한다.
|
|
onCorridorRefresh: async () => {
|
|
if (!currentSectionDetail || !latest?.route?.id) return;
|
|
const routePoints = latest.route_points ?? [];
|
|
if (routePoints.length <= 1) return;
|
|
await refreshCorridor(
|
|
viewer,
|
|
activeProjectId,
|
|
latest.route.id,
|
|
currentSectionDetail,
|
|
routePoints,
|
|
profilePanel.alignmentSamples() ?? undefined,
|
|
);
|
|
// 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③).
|
|
renderStationLines(currentSectionDetail);
|
|
},
|
|
onMovePoint: viewer.beginMoveSelected,
|
|
onDeletePoint: viewer.markers.deleteSelected,
|
|
onRadiusChange: (radius) => viewer.markers.updateSelected({ radius_m: radius }),
|
|
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),
|
|
onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage),
|
|
onPipeFacilitySelect: (chainage) => {
|
|
writeStructurePick(activeProjectId, chainage); // 목록 선택도 B06으로 이어진다.
|
|
if (selectionSyncing) return;
|
|
selectionSyncing = true;
|
|
try {
|
|
// null = 사이드 재클릭 해제 — 그래프·3D·배수유역도 선택도 함께 푼다(2026-08-18).
|
|
const station =
|
|
chainage === null
|
|
? undefined
|
|
: bridge
|
|
.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;
|
|
}
|
|
},
|
|
// 3D·종단·유역도에서 고른 것이 폼에 실릴 때 좌측 패널을 펼친다(2026-09-04 사용자).
|
|
onStructureReveal: () => layout.setOptionsOpen(true),
|
|
onStructureSelect: (structure) => {
|
|
if (selectionSyncing) return;
|
|
selectionSyncing = true;
|
|
try {
|
|
profilePanel.setSelectedStructure(structure?.structure_id ?? null);
|
|
} finally {
|
|
selectionSyncing = false;
|
|
}
|
|
},
|
|
});
|
|
|
|
wireStructurePick(viewer.structurePick, activeProjectId, panel.structures, selectStationOfPipe);
|
|
|
|
/** 입력·마커 변경 시 측점 라인만 다시 그린다 — 확정 게이트는 폐지(B06 통합 확정).
|
|
* 지형 구분·등급·기울기 세부 입력은 종단 그래프의 위반 판정 기준까지 **즉시**
|
|
* 갈아 끼운다(2026-08-19 사용자 지시 6 — 예전에는 재계산·재진입 때만 반영). */
|
|
function markStale(): void {
|
|
if (restoring || !routeReady) return;
|
|
profilePanel.setGradeLimit(panel.gradeLimitPct());
|
|
// 횡단배수 최소고 강제 체크는 재계산 없이 편집 가드에만 즉시 반영된다(2026-09-01).
|
|
profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance);
|
|
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 = bridge.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,
|
|
designSpeed: options.design_speed_kph as RoutePanelValues["designSpeed"] | 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,
|
|
enforcePipeClearance:
|
|
(next.route_params?.enforce_pipe_clearance as boolean | undefined) ?? 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,
|
|
bridge.irregularStations(),
|
|
detail.longitudinal.length_m,
|
|
// 중심 좌표는 노선 폴리라인 기준 — 3D 마커가 노선 위에 정확히 얹힌다(2026-08-19).
|
|
latest?.route_points ?? [],
|
|
);
|
|
// 예상형상이 켜져 있으면 측점 바를 **계획고** 기준으로 올린다 — 원지반 위에
|
|
// 얹힌 예상형상에 묻혀 측점 기준선이 안 보이던 문제(2026-08-23 사용자 지시).
|
|
const designAt = (chainageM: number): number | null => {
|
|
if (!corridorVisible) return null;
|
|
// 편집 중에는 라이브 alignment 샘플이 정본보다 새것이다(2026-08-23 지적 ③).
|
|
const samples =
|
|
profilePanel.alignmentSamples() ?? detail.longitudinal.design_profiles?.[0]?.samples;
|
|
if (!samples?.length) return null;
|
|
let best = samples[0];
|
|
for (const sample of samples) {
|
|
if (Math.abs(sample.chainage_m - chainageM) < Math.abs(best.chainage_m - chainageM)) {
|
|
best = sample;
|
|
}
|
|
}
|
|
return best.elevation_m;
|
|
};
|
|
// 측점 바 양 끝 램프용 상단측: 사용자 변경분 → solve 자동 판정 순으로 적용.
|
|
// 구조물 측점과 0.1m 안에서 겹치는 규칙 측점은 지운다 — 3D 측점 띠도 종단 그래프와
|
|
// 같은 규칙을 써야 두 화면의 측점이 어긋나지 않는다(2026-09-06).
|
|
const withUphill = [...dropStationsNear(regular, injected), ...injected].map((station) => {
|
|
const design = designAt(station.chainage_m);
|
|
return {
|
|
...station,
|
|
// 절토 구간에서는 계획고가 지반보다 **아래**다(2026-09-04 사용자 지적).
|
|
// max 로 잡으면 코리도가 절취해 내려간 노면을 두고 막대만 원지반에 떠 있다.
|
|
// 코리도가 켜져 있으면(designAt 이 값을 줌) 계획고를 그대로 쓴다.
|
|
center_z: design !== null && station.center_z !== null ? design : station.center_z,
|
|
uphill_side:
|
|
uphillOverrides.get(uphillKey(station.chainage_m)) ?? station.uphill_side ?? null,
|
|
};
|
|
});
|
|
viewer.renderStationLines(withUphill, roadWidths[panel.values().gradeClass] / 2);
|
|
}
|
|
|
|
/** 구조물·계곡 통과 시설을 사이드 목록·그래프·3D에 맞추는 다리(정본 저장까지 맡는다). */
|
|
const bridge = createStructuresBridge({
|
|
projectId: activeProjectId,
|
|
panel: () => panel,
|
|
profilePanel: () => profilePanel,
|
|
renderStationLines: () => {
|
|
if (currentSectionDetail) renderStationLines(currentSectionDetail);
|
|
},
|
|
isRestoring: () => restoring,
|
|
});
|
|
// 3D 노선 우클릭 → 구조물 배치 메뉴(그래프·배수유역도와 동일, 2026-08-19).
|
|
bridge.mountViewerMenu(viewer, () => latest?.route_points ?? []);
|
|
|
|
function renderSections(detail: SectionDetailResponse, routeId?: number): void {
|
|
currentSectionDetail = detail;
|
|
profilePanel.render(detail, panel.values().stationInterval ?? undefined, routeId);
|
|
// 저장된 계획선의 정책은 만들 당시 기준이라 지금 설정(임도 종류·설계속도·지형)과
|
|
// 다를 수 있다 — 그릴 때마다 현재 기준으로 맞춘다(2026-08-19). 이렇게 하지 않으면
|
|
// 진입 직후 상단 표시줄이 옛 상한을 그대로 보여 준다.
|
|
profilePanel.setGradeLimit(panel.gradeLimitPct());
|
|
// 횡단배수 최소고 강제 여부도 그릴 때마다 현재 설정으로 맞춘다(2026-09-01).
|
|
profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance);
|
|
profilePanel.setStationDisplay(panel.stationDisplayOffset());
|
|
profilePanel.setIrregularStations(bridge.irregularStations());
|
|
renderStationLines(detail);
|
|
// 예상형상 코리도 — 현재 종횡단 정본 그대로 빌드/로드해 3D에 반영(2026-08-23).
|
|
// 노선 폴리라인이 아직 없으면 미룬다 — 세분 기준이 없고, 로드 순서상
|
|
// renderLatest 후 재렌더가 오므로 그때 그린다(반복 빌드 방지).
|
|
const routePoints = latest?.route_points ?? [];
|
|
if (routePoints.length > 1) {
|
|
void refreshCorridor(
|
|
viewer,
|
|
activeProjectId,
|
|
routeId ?? latest?.route?.id,
|
|
detail,
|
|
routePoints,
|
|
profilePanel.alignmentSamples() ?? undefined,
|
|
);
|
|
// 방금 정본 그대로 그렸으므로 [3D 업데이트] 대기 표시를 지운다(2026-09-01).
|
|
panel.setCorridorPending(false);
|
|
}
|
|
}
|
|
|
|
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 ?? "",
|
|
}));
|
|
// 이관은 멱등이지만 **진입할 때마다** POST 가 나갔다(2026-09-06 실측). 탭 수명 동안
|
|
// 노선마다 한 번만 시도한다 — 옮길 것이 남아 있으면 다음 새로고침에 다시 본다.
|
|
const migrateKey = `${activeProjectId}:${routeId}`;
|
|
if (legacy.length && !migratedRoutes.has(migrateKey)) {
|
|
migratedRoutes.add(migrateKey);
|
|
void migrateLegacyStations(activeProjectId, legacy)
|
|
.then((result) => {
|
|
if (result.migrated > 0) {
|
|
showToast(`구 구조물 측점 ${result.migrated}건을 구조물 목록으로 옮겼습니다.`);
|
|
return bridge.refreshFromServer().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> {
|
|
// 화면 전체를 잠그지 않는다 — 진행 표시는 [재적용] 버튼이 맡는다(CLAUDE.md 5장).
|
|
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");
|
|
}
|
|
}
|
|
|
|
// 버튼 동작 3종([경로 계산]·[임시저장]·[초기화])은 따로 뗀 모듈이 맡는다(2026-09-02).
|
|
const actionContext: PageActionContext = {
|
|
projectId: activeProjectId,
|
|
latest: () => latest,
|
|
confirmedSurface: () => confirmedSurface,
|
|
routeReady: () => routeReady,
|
|
viewer: () => viewer,
|
|
panel: () => panel,
|
|
profilePanel: () => profilePanel,
|
|
bridge: () => bridge,
|
|
uphillOverrides,
|
|
persistUphillOverrides,
|
|
loadLatest,
|
|
renderLatest,
|
|
restoreSections,
|
|
};
|
|
|
|
/* ── 진입 로딩 ─────────────────────────────────────────────────────────
|
|
* 화면 틀을 먼저 띄우고 자료가 끝나는 순서대로 채운다(2026-08-01 사용자 지시).
|
|
* 3D 지형은 **보조 자료라 로딩에 넣지 않는다**(2026-09-04 사용자 지시) — 네 단계가
|
|
* 끝나면 로딩 표시를 걷어 화면을 바로 쓰게 하고, 3D는 뒤에서 올린 뒤 알린다.
|
|
* 서클은 3D 뷰포트 정중앙에 둔다(하단 종단 패널에 가려지는 것은 무방). */
|
|
const LOAD_STEP_COUNT = 4;
|
|
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 마커 복원에 쓴다.
|
|
// 캐시가 있으면 그것으로 **먼저** 그리고 신선도는 뒤에서 확인한다 — B05↔B06 이동에서
|
|
// 서버를 다시 부르지 않으려는 것이다(2026-09-06). 캐시가 없을 때만 DB를 기다린다.
|
|
const cachedLatest = readLatestCache();
|
|
const [latestResponse, sectionContext, configuredRoadWidths] = await Promise.all([
|
|
cachedLatest ? Promise.resolve(cachedLatest) : loadLatest(true),
|
|
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,
|
|
// 임도 종류는 프로젝트 등록(B02)이 정본이다 — 화면에서 고르지 않는다
|
|
// (2026-08-19 사용자 확정). B02의 main/branch/fire/stream을 계획선 기준
|
|
// 코드로 옮긴다: main=간선, fire=산불진화, 그 밖(구 지선·계류보전)은 작업.
|
|
gradeClass: toGradeClass(sectionContext.road_type),
|
|
});
|
|
// 유토곡선용 설정 — 토량환산계수·운반장비 경계·자연방토 판정 경사를 config_system
|
|
// 정의 그대로 받아 B06과 같은 계산을 태운다(프론트 사본 금지).
|
|
profilePanel.setEarthworkContext({
|
|
conversion: sectionContext.earthwork_conversion,
|
|
haulLimits: sectionContext.haul_equipment_limits,
|
|
naturalSpoilMinSlope: sectionContext.natural_spoil_min_ground_slope ?? undefined,
|
|
});
|
|
// 표준횡단 config 기본값도 같은 방식으로 기억한다 — B05에는 「표준 횡단면 설정」
|
|
// 패널이 없지만, 계획선 편집 중 횡단 재계산이 브라우저에서 돌아 이 값이 필요하다
|
|
// (2026-09-03 로컬 계산 전환). 프론트에 config 사본을 두지 않으려는 같은 원칙이다.
|
|
rememberStandardDefaults(activeProjectId, sectionContext.standard_cross_section);
|
|
rememberRockBoundaryDefault(activeProjectId, sectionContext.rock_boundary_default_offset_m);
|
|
restorePanel(latestResponse);
|
|
renderLatest(latestResponse);
|
|
latest = latestResponse;
|
|
advanceLoading("확정 지표면 모델을 확인하는 중…");
|
|
|
|
// ③ 확정 지표면 — 모델 id·범위를 한 번에 받는다.
|
|
const confirmed = await fetchConfirmedSurface(activeProjectId);
|
|
confirmedSurface = confirmed.model_id === null ? null : confirmed;
|
|
if (!confirmedSurface) {
|
|
// 새 자료가 올라와 옛 결과가 지워진 상태 — 여기서 보여 줄 게 없다.
|
|
leaveForDashboard();
|
|
return;
|
|
}
|
|
advanceLoading("종단면 자료를 불러오는 중…");
|
|
|
|
// ④ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다.
|
|
if (latestResponse.route) await restoreSections(latestResponse.route.id);
|
|
// 구조물 타입 레지스트리·정본 — 노선이 없어도 목록은 보여 준다(추가는 노선 이후).
|
|
await bridge.load();
|
|
advanceLoading("");
|
|
// 캐시로 그렸다면 이제 뒤에서 신선도만 확인한다 — 화면은 이미 서 있으므로 기다리지
|
|
// 않는다. 다른 탭이 자료를 갈아 끼웠을 때만 다시 그린다.
|
|
if (cachedLatest) void verifyLatestFreshness(latestResponse);
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : "화면을 불러오지 못했습니다.", "error");
|
|
} finally {
|
|
progress.remove();
|
|
restoring = false;
|
|
restorePick(); // 관·구조물 목록 중 늦게 오는 쪽이 있어 여기서 한 번 더.
|
|
}
|
|
|
|
// ⑤ 3D 지형 — 화면을 잡지 않고 뒤에서 올린다. 실패해도 나머지는 그대로 쓴다.
|
|
void (async () => {
|
|
const [surface, current] = [confirmedSurface, latest];
|
|
if (!surface || !current) return;
|
|
try {
|
|
// 범위는 ③에서 받아 둔 확정 응답에 이미 들어 있다 — 다시 부르지 않는다.
|
|
if (!surface.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다.");
|
|
await viewer.loadSurface(
|
|
activeProjectId,
|
|
surface.model_id as number,
|
|
current.surface_params.method,
|
|
current.surface_params.smooth,
|
|
current.surface_params.contour_interval_m,
|
|
toBounds(surface.bounds),
|
|
);
|
|
renderLatest(current); // 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다.
|
|
if (currentSectionDetail) renderStationLines(currentSectionDetail);
|
|
showToast("3D 지형 준비 완료", "success");
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : "3D 지형을 불러오지 못했습니다.", "error");
|
|
}
|
|
})();
|
|
}
|