refactor(종단보기): 345-1 design_view_profile 쪼갬 — 노선 · 종단면 그리기(_sections) · 3D 몫(_3d) · 상태 한 벌(ProfileViewState) · 동작 그대로
- design_view_profile.ts 700 → 517줄 · _sections 179 · _3d 114 - 시험: 관련 51벌 370 통과(이전과 같은 3 실패 = 205_1 · 더하기 글 · 손 안 댄 곳) · typecheck 통과 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
This commit is contained in:
@@ -5,21 +5,15 @@
|
|||||||
*
|
*
|
||||||
* 여기서는 종단 보기 몫만 건다 — 3D · 좌측 종단 칸 재료 · 구조물 다리 · 종단도 알림 · 저장 몫.
|
* 여기서는 종단 보기 몫만 건다 — 3D · 좌측 종단 칸 재료 · 구조물 다리 · 종단도 알림 · 저장 몫.
|
||||||
* (옛 `B05_Profile_UI_Page.ts` 의 본체다. 버튼 동작은 `_Page_Actions`, 최신 노선 캐시는
|
* (옛 `B05_Profile_UI_Page.ts` 의 본체다. 버튼 동작은 `_Page_Actions`, 최신 노선 캐시는
|
||||||
* `_Page_Latest`, 3D 측점 바는 `_Page_Stations`, 숨김 · 보임은 `_Page_Life` 가 맡는다.)
|
* `_Page_Latest`, 3D 측점 바는 `_Page_Stations`, 숨김 · 보임은 `_Page_Life` 가 맡는다.
|
||||||
|
* 노선 · 종단면 그리기는 `design_view_profile_sections`, 3D 몫은 `design_view_profile_3d`.)
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import { showToast } from "@ui/ui_template_elements";
|
import { showToast } from "@ui/ui_template_elements";
|
||||||
import { purgeOtherProjects } from "../b_asset_cache";
|
import { purgeOtherProjects } from "../b_asset_cache";
|
||||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||||
import {
|
import { fetchConfirmedSurface } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||||
fetchConfirmedSurface,
|
import { clearRouteLatestCache } from "../../B05_Profile/B05_Profile_Api_Fetch";
|
||||||
type SurfaceConfirmedResponse,
|
|
||||||
} from "../../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
|
||||||
import {
|
|
||||||
clearRouteLatestCache,
|
|
||||||
updateContourInterval,
|
|
||||||
type RouteLatestResponse,
|
|
||||||
} from "../../B05_Profile/B05_Profile_Api_Fetch";
|
|
||||||
import { createRoutePanel } from "../../B05_Profile/B05_Profile_UI_Panel";
|
import { createRoutePanel } from "../../B05_Profile/B05_Profile_UI_Panel";
|
||||||
import { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_Profile_Panel";
|
import { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_Profile_Panel";
|
||||||
import { leaveForDashboard } from "../b_missing_data_guard";
|
import { leaveForDashboard } from "../b_missing_data_guard";
|
||||||
@@ -51,28 +45,18 @@ import {
|
|||||||
rememberRockBoundaryDefault,
|
rememberRockBoundaryDefault,
|
||||||
rememberStandardDefaults,
|
rememberStandardDefaults,
|
||||||
} from "../../B06_Section/B06_Section_UI_Standard_Panel";
|
} from "../../B06_Section/B06_Section_UI_Standard_Panel";
|
||||||
|
import { fetchSectionContext } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||||
import {
|
import {
|
||||||
fetchSectionContext,
|
|
||||||
type SectionDetailResponse,
|
|
||||||
} from "../../B06_Section/B06_Section_Api_Fetch";
|
|
||||||
import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
|
|
||||||
import {
|
|
||||||
migrateLegacyStations,
|
|
||||||
onStaleStructureTypes,
|
onStaleStructureTypes,
|
||||||
structureAnchorM,
|
structureAnchorM,
|
||||||
} from "../../B05_Profile/B05_Profile_Api_Structures";
|
} from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||||
import { pipeToFacilityItem } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types";
|
import { pipeToFacilityItem } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types";
|
||||||
import {
|
import { saveCorridorIfDirty } from "../../B05_Profile/B05_Profile_UI_Corridor";
|
||||||
loadCorridorIfFresh,
|
|
||||||
refreshCorridor,
|
|
||||||
saveCorridorIfDirty,
|
|
||||||
} from "../../B05_Profile/B05_Profile_UI_Corridor";
|
|
||||||
import {
|
import {
|
||||||
profileSaveParts,
|
profileSaveParts,
|
||||||
type PageActionContext,
|
type PageActionContext,
|
||||||
} from "../../B05_Profile/B05_Profile_UI_Page_Actions";
|
} from "../../B05_Profile/B05_Profile_UI_Page_Actions";
|
||||||
import { createLatestSource, panelValuesOf } from "../../B05_Profile/B05_Profile_UI_Page_Latest";
|
import { createLatestSource } from "../../B05_Profile/B05_Profile_UI_Page_Latest";
|
||||||
import { stationLinesOf } from "../../B05_Profile/B05_Profile_UI_Page_Stations";
|
|
||||||
import { setPipeCrossOpener, setPipeSectionsSource } from "./design_structure_pipe_calc";
|
import { setPipeCrossOpener, setPipeSectionsSource } from "./design_structure_pipe_calc";
|
||||||
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
|
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
|
||||||
import "../../B05_Profile/B05_Profile_UI_Style.css";
|
import "../../B05_Profile/B05_Profile_UI_Style.css";
|
||||||
@@ -85,14 +69,11 @@ import {
|
|||||||
DEFAULT_ROAD_WIDTHS,
|
DEFAULT_ROAD_WIDTHS,
|
||||||
fetchRoadWidths,
|
fetchRoadWidths,
|
||||||
loadUphillOverrides,
|
loadUphillOverrides,
|
||||||
restorePoints,
|
|
||||||
saveUphillOverrides,
|
saveUphillOverrides,
|
||||||
toBounds,
|
|
||||||
toGradeClass,
|
toGradeClass,
|
||||||
} from "../../B05_Profile/B05_Profile_UI_Page_Helpers";
|
} from "../../B05_Profile/B05_Profile_UI_Page_Helpers";
|
||||||
|
import { createProfileSections, type ProfileViewState } from "./design_view_profile_sections";
|
||||||
/** 구 구조물 측점 이관을 이미 시도한 노선 — 탭 수명 동안 유지한다. */
|
import { createProfile3d } from "./design_view_profile_3d";
|
||||||
const migratedRoutes = new Set<string>();
|
|
||||||
|
|
||||||
type PipeReport = Parameters<NonNullable<RouteProfilePanelCallbacks["onPipesChanged"]>>[0];
|
type PipeReport = Parameters<NonNullable<RouteProfilePanelCallbacks["onPipesChanged"]>>[0];
|
||||||
|
|
||||||
@@ -133,7 +114,7 @@ export async function mountProfileView(
|
|||||||
* 관도 없는 자리면 그 측점선 · 3D 띠(횡단에서 고른 일반 측점 · 188-1) — 구조물 고름은 선 없음(189-1). */
|
* 관도 없는 자리면 그 측점선 · 3D 띠(횡단에서 고른 일반 측점 · 188-1) — 구조물 고름은 선 없음(189-1). */
|
||||||
const selectGraphAt = (id: string | null, chainageM: number | null): void => {
|
const selectGraphAt = (id: string | null, chainageM: number | null): void => {
|
||||||
const structurePick = !!readSelection(activeProjectId)?.structureId;
|
const structurePick = !!readSelection(activeProjectId)?.structureId;
|
||||||
const sections = structurePick ? [] : (currentSectionDetail?.cross_sections ?? []);
|
const sections = structurePick ? [] : (state.detail?.cross_sections ?? []);
|
||||||
const target = graphTargetAt(id, chainageM, lastPipes ?? [], sections);
|
const target = graphTargetAt(id, chainageM, lastPipes ?? [], sections);
|
||||||
if ("pipeChainageM" in target)
|
if ("pipeChainageM" in target)
|
||||||
return profilePanel.setSelectedStructure(pipeIdAt(target.pipeChainageM, activeProjectId));
|
return profilePanel.setSelectedStructure(pipeIdAt(target.pipeChainageM, activeProjectId));
|
||||||
@@ -199,19 +180,19 @@ export async function mountProfileView(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
/** 확정 지표면 — 모델 id 와 3D 로딩에 필요한 범위만 들고 있는다(2026-09-06 호출 정리). */
|
const state: ProfileViewState = {
|
||||||
let confirmedSurface: SurfaceConfirmedResponse | null = null;
|
confirmedSurface: null,
|
||||||
let latest: RouteLatestResponse | null = null;
|
latest: null,
|
||||||
let roadWidths = DEFAULT_ROAD_WIDTHS;
|
roadWidths: DEFAULT_ROAD_WIDTHS,
|
||||||
let currentSectionDetail: SectionDetailResponse | null = null;
|
detail: null,
|
||||||
|
corridorVisible: true,
|
||||||
|
routeReady: false,
|
||||||
|
restoring: true,
|
||||||
|
};
|
||||||
// 배관 관 길이 · 흙막이 개수(54-1) — 종단 보기도 같은 횡단 자료로(B05 = B06)
|
// 배관 관 길이 · 흙막이 개수(54-1) — 종단 보기도 같은 횡단 자료로(B05 = B06)
|
||||||
setPipeSectionsSource(() => currentSectionDetail?.cross_sections ?? null);
|
setPipeSectionsSource(() => state.detail?.cross_sections ?? null);
|
||||||
// 좌측 단 수를 바꾸면 횡단 보기를 숨긴 채 세워 같은 단 수 제어로 씀(54-27)
|
// 좌측 단 수를 바꾸면 횡단 보기를 숨긴 채 세워 같은 단 수 제어로 씀(54-27)
|
||||||
setPipeCrossOpener(() => shared.ensureView("cross"));
|
setPipeCrossOpener(() => shared.ensureView("cross"));
|
||||||
/** [예상형상] 표시 상태 — 측점 바를 계획고 위로 올릴지 판단한다(패널 기본 ON). */
|
|
||||||
let corridorVisible = true;
|
|
||||||
let routeReady = false;
|
|
||||||
let restoring = true;
|
|
||||||
// 선택 동기화 재진입 가드(3D↔그래프↔사이드바 상호 갱신의 무한 재귀 차단).
|
// 선택 동기화 재진입 가드(3D↔그래프↔사이드바 상호 갱신의 무한 재귀 차단).
|
||||||
let selectionSyncing = false;
|
let selectionSyncing = false;
|
||||||
|
|
||||||
@@ -239,8 +220,7 @@ export async function mountProfileView(
|
|||||||
markStation: (chainageM) => profilePanel.drainage.markStation(chainageM),
|
markStation: (chainageM) => profilePanel.drainage.markStation(chainageM),
|
||||||
selectStructure3D: (chainageM) => viewer.structurePick.selectAtChainage(chainageM),
|
selectStructure3D: (chainageM) => viewer.structurePick.selectAtChainage(chainageM),
|
||||||
regularChainage: (stationId) =>
|
regularChainage: (stationId) =>
|
||||||
currentSectionDetail?.cross_sections.find((entry) => entry.station_id === stationId)
|
state.detail?.cross_sections.find((entry) => entry.station_id === stationId)?.chainage_m,
|
||||||
?.chainage_m,
|
|
||||||
remember: (chainageM) => rememberPick(chainageM, null),
|
remember: (chainageM) => rememberPick(chainageM, null),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -253,7 +233,7 @@ export async function mountProfileView(
|
|||||||
let oldSnapped = false;
|
let oldSnapped = false;
|
||||||
/** 옛 소수 측점(관 · 구조물)을 정수 m 로 — 관 · 구조물 · 계획고 편집이 다 실린 뒤 한 번(272-4). */
|
/** 옛 소수 측점(관 · 구조물)을 정수 m 로 — 관 · 구조물 · 계획고 편집이 다 실린 뒤 한 번(272-4). */
|
||||||
function snapOld(): void {
|
function snapOld(): void {
|
||||||
if (oldSnapped || restoring || !lastPipes) return;
|
if (oldSnapped || state.restoring || !lastPipes) return;
|
||||||
oldSnapped = true;
|
oldSnapped = true;
|
||||||
panel.structures.snapOld();
|
panel.structures.snapOld();
|
||||||
}
|
}
|
||||||
@@ -269,6 +249,24 @@ export async function mountProfileView(
|
|||||||
|
|
||||||
const latestSource = createLatestSource(activeProjectId);
|
const latestSource = createLatestSource(activeProjectId);
|
||||||
const loadLatest = latestSource.load;
|
const loadLatest = latestSource.load;
|
||||||
|
const sections = createProfileSections({
|
||||||
|
projectId: activeProjectId,
|
||||||
|
state,
|
||||||
|
viewer,
|
||||||
|
panel: () => panel,
|
||||||
|
profilePanel: () => profilePanel,
|
||||||
|
bridge: () => bridge,
|
||||||
|
});
|
||||||
|
const { restorePanel, renderLatest, restoreSections } = sections;
|
||||||
|
const view3d = createProfile3d({
|
||||||
|
projectId: activeProjectId,
|
||||||
|
state,
|
||||||
|
viewer,
|
||||||
|
host,
|
||||||
|
latestSource,
|
||||||
|
profilePanel: () => profilePanel,
|
||||||
|
sections: () => sections,
|
||||||
|
});
|
||||||
|
|
||||||
const panel = createRoutePanel(
|
const panel = createRoutePanel(
|
||||||
{
|
{
|
||||||
@@ -280,44 +278,12 @@ export async function mountProfileView(
|
|||||||
// 측점 눈금 간격은 좌측 패널이 쥔 값을 그대로 넘긴다 — 모달이 따로 굳히지 않는다.
|
// 측점 눈금 간격은 좌측 패널이 쥔 값을 그대로 넘긴다 — 모달이 따로 굳히지 않는다.
|
||||||
stationIntervalM: panel.values().stationInterval ?? undefined,
|
stationIntervalM: panel.values().stationInterval ?? undefined,
|
||||||
// 바탕 등고선 — 확정 지표면이 있으면 모달이 LAS 등고선을 쓴다(계획서 0-9 ⑥).
|
// 바탕 등고선 — 확정 지표면이 있으면 모달이 LAS 등고선을 쓴다(계획서 0-9 ⑥).
|
||||||
surfaceModelId: confirmedSurface?.model_id ?? null,
|
surfaceModelId: state.confirmedSurface?.model_id ?? null,
|
||||||
contourIntervalM: latest?.surface_params.contour_interval_m,
|
contourIntervalM: state.latest?.surface_params.contour_interval_m,
|
||||||
smooth: latest?.surface_params.smooth,
|
smooth: state.latest?.surface_params.smooth,
|
||||||
}),
|
}),
|
||||||
onContourApply: (interval) => applyContours(interval),
|
onContourApply: (interval) => view3d.applyContours(interval),
|
||||||
onSurfaceVisible: viewer.setSurfaceVisible,
|
...view3d.viewCallbacks,
|
||||||
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);
|
|
||||||
// 만든 것을 **바로 올려 둔다**(2026-09-06) — 다음 진입이 네트워크 없이 선다.
|
|
||||||
void saveCorridorIfDirty(activeProjectId, latest.route.id);
|
|
||||||
},
|
|
||||||
onMovePoint: viewer.beginMoveSelected,
|
onMovePoint: viewer.beginMoveSelected,
|
||||||
onDeletePoint: viewer.markers.deleteSelected,
|
onDeletePoint: viewer.markers.deleteSelected,
|
||||||
onRadiusChange: (radius) => viewer.markers.updateSelected({ radius_m: radius }),
|
onRadiusChange: (radius) => viewer.markers.updateSelected({ radius_m: radius }),
|
||||||
@@ -362,11 +328,11 @@ export async function mountProfileView(
|
|||||||
/** 입력·마커 변경 시 측점 라인만 다시 그린다. 지형 구분·등급·기울기 세부 입력은 종단 그래프의
|
/** 입력·마커 변경 시 측점 라인만 다시 그린다. 지형 구분·등급·기울기 세부 입력은 종단 그래프의
|
||||||
* 위반 판정 기준까지 **즉시** 갈아 끼운다(2026-08-19 사용자 지시 6). */
|
* 위반 판정 기준까지 **즉시** 갈아 끼운다(2026-08-19 사용자 지시 6). */
|
||||||
function markStale(): void {
|
function markStale(): void {
|
||||||
if (restoring || !routeReady) return;
|
if (state.restoring || !state.routeReady) return;
|
||||||
profilePanel.setGradeLimit(panel.gradeLimitPct());
|
profilePanel.setGradeLimit(panel.gradeLimitPct());
|
||||||
// 횡단배수 최소고 강제 체크는 재계산 없이 편집 가드에만 즉시 반영된다(2026-09-01).
|
// 횡단배수 최소고 강제 체크는 재계산 없이 편집 가드에만 즉시 반영된다(2026-09-01).
|
||||||
profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance);
|
profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance);
|
||||||
if (currentSectionDetail) renderStationLines(currentSectionDetail);
|
sections.redrawStationLines();
|
||||||
}
|
}
|
||||||
|
|
||||||
viewer.markers.onChange(markStale);
|
viewer.markers.onChange(markStale);
|
||||||
@@ -377,31 +343,13 @@ export async function mountProfileView(
|
|||||||
});
|
});
|
||||||
viewer.root.append(panel.viewControls);
|
viewer.root.append(panel.viewControls);
|
||||||
|
|
||||||
function restorePanel(next: RouteLatestResponse): void {
|
|
||||||
panel.restore(panelValuesOf(next));
|
|
||||||
viewer.markers.setPoints(restorePoints(next));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderStationLines(detail: SectionDetailResponse): void {
|
|
||||||
const lines = stationLinesOf({
|
|
||||||
detail,
|
|
||||||
irregular: bridge.irregularStations(),
|
|
||||||
routePoints: latest?.route_points ?? [],
|
|
||||||
liveSamples: profilePanel.alignmentSamples(),
|
|
||||||
corridorVisible,
|
|
||||||
});
|
|
||||||
viewer.renderStationLines(lines, roadWidths[panel.values().gradeClass] / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 구조물·계곡 통과 시설을 사이드 목록·그래프·3D에 맞추는 다리(정본 저장까지 맡는다). */
|
/** 구조물·계곡 통과 시설을 사이드 목록·그래프·3D에 맞추는 다리(정본 저장까지 맡는다). */
|
||||||
const bridge = createStructuresBridge({
|
const bridge = createStructuresBridge({
|
||||||
projectId: activeProjectId,
|
projectId: activeProjectId,
|
||||||
panel: () => panel,
|
panel: () => panel,
|
||||||
profilePanel: () => profilePanel,
|
profilePanel: () => profilePanel,
|
||||||
renderStationLines: () => {
|
renderStationLines: () => sections.redrawStationLines(),
|
||||||
if (currentSectionDetail) renderStationLines(currentSectionDetail);
|
isRestoring: () => state.restoring,
|
||||||
},
|
|
||||||
isRestoring: () => restoring,
|
|
||||||
});
|
});
|
||||||
// 종단도 · 3D 강조 = 고름 한 칸에서 파생 · 이 보기가 보일 때만(횡단 보기는 제 규칙 · 233-1 · 233-2)
|
// 종단도 · 3D 강조 = 고름 한 칸에서 파생 · 이 보기가 보일 때만(횡단 보기는 제 규칙 · 233-1 · 233-2)
|
||||||
const profilePick = bindProfilePick(
|
const profilePick = bindProfilePick(
|
||||||
@@ -411,129 +359,18 @@ export async function mountProfileView(
|
|||||||
{
|
{
|
||||||
irregular: () => bridge.irregularStations(),
|
irregular: () => bridge.irregularStations(),
|
||||||
pipes: () => lastPipes ?? [],
|
pipes: () => lastPipes ?? [],
|
||||||
sections: () => currentSectionDetail?.cross_sections ?? [],
|
sections: () => state.detail?.cross_sections ?? [],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
// 3D 노선 우클릭 → 구조물 배치 메뉴(그래프·배수유역도와 동일, 2026-08-19).
|
// 3D 노선 우클릭 → 구조물 배치 메뉴(그래프·배수유역도와 동일, 2026-08-19).
|
||||||
bridge.mountViewerMenu(viewer, () => latest?.route_points ?? []);
|
bridge.mountViewerMenu(viewer, () => state.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());
|
|
||||||
profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance);
|
|
||||||
profilePanel.setStationDisplay(panel.stationDisplayOffset());
|
|
||||||
profilePanel.setIrregularStations(bridge.irregularStations());
|
|
||||||
renderStationLines(detail);
|
|
||||||
// 예상형상 코리도 — 저장본이 지금 정본과 맞을 때만 올린다. 낡았으면 [3D 업데이트] 대기
|
|
||||||
// 표시만 켠다(2026-09-06 실측: 받아놓고 버리는 데다 다시 만드느라 전환이 16.7초까지 갔다).
|
|
||||||
const routePoints = latest?.route_points ?? [];
|
|
||||||
if (routePoints.length > 1) {
|
|
||||||
void loadCorridorIfFresh(
|
|
||||||
viewer,
|
|
||||||
activeProjectId,
|
|
||||||
routeId ?? latest?.route?.id,
|
|
||||||
detail,
|
|
||||||
routePoints,
|
|
||||||
profilePanel.alignmentSamples() ?? undefined,
|
|
||||||
).then((fresh) => panel.setCorridorPending(!fresh));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function restoreSections(routeId: number): Promise<void> {
|
|
||||||
let detail: SectionDetailResponse;
|
|
||||||
profilePanel.setLoading("종단면 자료를 불러오는 중…");
|
|
||||||
try {
|
|
||||||
// 공유 캐시 — 횡단 보기가 이미 받아 뒀으면 같은 객체를 재사용해 두 보기가 같은 값을 본다.
|
|
||||||
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 · 09-06 실측).
|
|
||||||
const legacy = detail.longitudinal.stations
|
|
||||||
.filter((station) => station.kind === "irregular")
|
|
||||||
.map((station) => ({
|
|
||||||
chainage_m: station.chainage_m,
|
|
||||||
structure: station.structure ?? "",
|
|
||||||
}));
|
|
||||||
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 = latestSource.read();
|
|
||||||
if (cached) {
|
|
||||||
latestSource.write({
|
|
||||||
...cached,
|
|
||||||
surface_params: { ...cached.surface_params, contour_interval_m: interval },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showToast(error instanceof Error ? error.message : "등고선 조회에 실패했습니다.", "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// [경로 계산] · 저장 몫은 따로 뗀 모듈이 맡는다(2026-09-02).
|
// [경로 계산] · 저장 몫은 따로 뗀 모듈이 맡는다(2026-09-02).
|
||||||
const actionContext: PageActionContext = {
|
const actionContext: PageActionContext = {
|
||||||
projectId: activeProjectId,
|
projectId: activeProjectId,
|
||||||
latest: () => latest,
|
latest: () => state.latest,
|
||||||
confirmedSurface: () => confirmedSurface,
|
confirmedSurface: () => state.confirmedSurface,
|
||||||
routeReady: () => routeReady,
|
routeReady: () => state.routeReady,
|
||||||
viewer: () => viewer,
|
viewer: () => viewer,
|
||||||
panel: () => panel,
|
panel: () => panel,
|
||||||
profilePanel: () => profilePanel,
|
profilePanel: () => profilePanel,
|
||||||
@@ -551,13 +388,13 @@ export async function mountProfileView(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0)
|
// 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0)
|
||||||
const reloadIfShown = (): void => void (host.visible() && !restoring && bridge.load());
|
const reloadIfShown = (): void => void (host.visible() && !state.restoring && bridge.load());
|
||||||
host.onDispose(onStaleStructureTypes(reloadIfShown));
|
host.onDispose(onStaleStructureTypes(reloadIfShown));
|
||||||
// 한 페이지 두 보기 — 숨김 · 보임 · 떠남(계획서 24-3a · 24-3b).
|
// 한 페이지 두 보기 — 숨김 · 보임 · 떠남(계획서 24-3a · 24-3b).
|
||||||
bindProfileViewLife(host, {
|
bindProfileViewLife(host, {
|
||||||
projectId: activeProjectId,
|
projectId: activeProjectId,
|
||||||
viewer,
|
viewer,
|
||||||
ready: () => !restoring,
|
ready: () => !state.restoring,
|
||||||
// 다시 보이면 횡단 보기가 한 벌(폼 · 종단도)에 실어 둔 값을 종단 보기 값으로 되돌린다.
|
// 다시 보이면 횡단 보기가 한 벌(폼 · 종단도)에 실어 둔 값을 종단 보기 값으로 되돌린다.
|
||||||
reload: async () => {
|
reload: async () => {
|
||||||
await bridge.load();
|
await bridge.load();
|
||||||
@@ -577,7 +414,7 @@ export async function mountProfileView(
|
|||||||
},
|
},
|
||||||
// 보기를 떠나는 자리 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다.
|
// 보기를 떠나는 자리 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다.
|
||||||
onHide: () => {
|
onHide: () => {
|
||||||
if (latest?.route?.id) void saveCorridorIfDirty(activeProjectId, latest.route.id);
|
if (state.latest?.route?.id) void saveCorridorIfDirty(activeProjectId, state.latest.route.id);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -609,7 +446,7 @@ export async function mountProfileView(
|
|||||||
fetchRoadWidths(activeProjectId),
|
fetchRoadWidths(activeProjectId),
|
||||||
]);
|
]);
|
||||||
if (host.disposed()) return;
|
if (host.disposed()) return;
|
||||||
roadWidths = configuredRoadWidths;
|
state.roadWidths = configuredRoadWidths;
|
||||||
panel.restore({
|
panel.restore({
|
||||||
stationInterval: sectionContext.defaults.station_interval_m,
|
stationInterval: sectionContext.defaults.station_interval_m,
|
||||||
crossSampleInterval: sectionContext.defaults.cross_sample_interval_m,
|
crossSampleInterval: sectionContext.defaults.cross_sample_interval_m,
|
||||||
@@ -633,8 +470,8 @@ export async function mountProfileView(
|
|||||||
|
|
||||||
// ② 확정 지표면 — 모델 id·범위를 한 번에 받는다.
|
// ② 확정 지표면 — 모델 id·범위를 한 번에 받는다.
|
||||||
const confirmed = await fetchConfirmedSurface(activeProjectId);
|
const confirmed = await fetchConfirmedSurface(activeProjectId);
|
||||||
confirmedSurface = confirmed.model_id === null ? null : confirmed;
|
state.confirmedSurface = confirmed.model_id === null ? null : confirmed;
|
||||||
if (!confirmedSurface) {
|
if (!state.confirmedSurface) {
|
||||||
// 새 자료가 올라와 옛 결과가 지워진 상태 — 여기서 보여 줄 게 없다.
|
// 새 자료가 올라와 옛 결과가 지워진 상태 — 여기서 보여 줄 게 없다.
|
||||||
leaveForDashboard();
|
leaveForDashboard();
|
||||||
return;
|
return;
|
||||||
@@ -668,33 +505,11 @@ export async function mountProfileView(
|
|||||||
showToast(error instanceof Error ? error.message : "화면을 불러오지 못했습니다.", "error");
|
showToast(error instanceof Error ? error.message : "화면을 불러오지 못했습니다.", "error");
|
||||||
} finally {
|
} finally {
|
||||||
progress.remove();
|
progress.remove();
|
||||||
restoring = false;
|
state.restoring = false;
|
||||||
snapOld();
|
snapOld();
|
||||||
restorePick(); // 관·구조물 목록 중 늦게 오는 쪽이 있어 여기서 한 번 더.
|
restorePick(); // 관·구조물 목록 중 늦게 오는 쪽이 있어 여기서 한 번 더.
|
||||||
}
|
}
|
||||||
|
|
||||||
// ④ 3D 지형 — 화면을 잡지 않고 뒤에서 올린다. 실패해도 나머지는 그대로 쓴다.
|
// ④ 3D 지형 — 화면을 잡지 않고 뒤에서 올린다. 실패해도 나머지는 그대로 쓴다.
|
||||||
void (async () => {
|
void view3d.loadTerrain();
|
||||||
const surface = confirmedSurface;
|
|
||||||
const current = latest as RouteLatestResponse | null; // 닫힘 안에서 채워져 흐름 분석이 못 봄
|
|
||||||
if (!surface || !current || host.disposed()) 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),
|
|
||||||
);
|
|
||||||
if (host.disposed()) return; // 올리는 사이 페이지를 떠났다.
|
|
||||||
renderLatest(current); // 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다.
|
|
||||||
if (currentSectionDetail) renderStationLines(currentSectionDetail);
|
|
||||||
showToast("3D 지형 준비 완료", "success");
|
|
||||||
} catch (error) {
|
|
||||||
showToast(error instanceof Error ? error.message : "3D 지형을 불러오지 못했습니다.", "error");
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* design_view_profile_3d.ts
|
||||||
|
* 종단 보기의 **3D 몫** — 보기 조작 줄 콜백 · [3D 업데이트] · 등고선 다시 적용 · 3D 지형 올리기.
|
||||||
|
* `design_view_profile.ts` 가 700줄을 넘어 뗀 조각(345-1). 상태는 본체가 쥔 `ProfileViewState`.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { showToast } from "@ui/ui_template_elements";
|
||||||
|
import { updateContourInterval } from "../../B05_Profile/B05_Profile_Api_Fetch";
|
||||||
|
import { refreshCorridor, saveCorridorIfDirty } from "../../B05_Profile/B05_Profile_UI_Corridor";
|
||||||
|
import type { createLatestSource } from "../../B05_Profile/B05_Profile_UI_Page_Latest";
|
||||||
|
import { toBounds } from "../../B05_Profile/B05_Profile_UI_Page_Helpers";
|
||||||
|
import type { ViewControlCallbacks } from "../../B05_Profile/B05_Profile_UI_Panel_View";
|
||||||
|
import type { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_Profile_Panel";
|
||||||
|
import type { RouteViewer } from "../../B05_Profile/B05_Profile_UI_Viewer";
|
||||||
|
import type { DesignPageHost } from "./design_page";
|
||||||
|
import type { createProfileSections, ProfileViewState } from "./design_view_profile_sections";
|
||||||
|
|
||||||
|
export interface Profile3dDeps {
|
||||||
|
projectId: string;
|
||||||
|
state: ProfileViewState;
|
||||||
|
viewer: RouteViewer;
|
||||||
|
host: DesignPageHost;
|
||||||
|
latestSource: ReturnType<typeof createLatestSource>;
|
||||||
|
profilePanel: () => ReturnType<typeof createRouteProfilePanel>;
|
||||||
|
sections: () => ReturnType<typeof createProfileSections>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProfile3d(deps: Profile3dDeps) {
|
||||||
|
const { projectId, state, viewer } = deps;
|
||||||
|
|
||||||
|
/** 보기 조작 줄(ISO · 켬끔 · [3D 업데이트])이 부르는 것 — 좌측 패널 콜백에 섞어 넘긴다. */
|
||||||
|
const viewCallbacks: ViewControlCallbacks = {
|
||||||
|
onSurfaceVisible: viewer.setSurfaceVisible,
|
||||||
|
onCorridorVisible: (visible) => {
|
||||||
|
state.corridorVisible = visible;
|
||||||
|
viewer.setCorridorVisible(visible);
|
||||||
|
// 측점 바 기준면이 바뀐다(예상형상 위 ↔ 원지반 위) — 다시 그린다.
|
||||||
|
deps.sections().redrawStationLines();
|
||||||
|
},
|
||||||
|
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 () => {
|
||||||
|
const detail = state.detail;
|
||||||
|
const latest = state.latest;
|
||||||
|
if (!detail || !latest?.route?.id) return;
|
||||||
|
const routePoints = latest.route_points ?? [];
|
||||||
|
if (routePoints.length <= 1) return;
|
||||||
|
await refreshCorridor(
|
||||||
|
viewer,
|
||||||
|
projectId,
|
||||||
|
latest.route.id,
|
||||||
|
detail,
|
||||||
|
routePoints,
|
||||||
|
deps.profilePanel().alignmentSamples() ?? undefined,
|
||||||
|
);
|
||||||
|
// 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③).
|
||||||
|
deps.sections().renderStationLines(detail);
|
||||||
|
// 만든 것을 **바로 올려 둔다**(2026-09-06) — 다음 진입이 네트워크 없이 선다.
|
||||||
|
void saveCorridorIfDirty(projectId, latest.route.id);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function applyContours(interval: number): Promise<void> {
|
||||||
|
// 화면 전체를 잠그지 않는다 — 진행 표시는 [재적용] 버튼이 맡는다(CLAUDE.md 5장).
|
||||||
|
try {
|
||||||
|
await viewer.reloadContours(interval);
|
||||||
|
await updateContourInterval(projectId, interval);
|
||||||
|
// 세션 캐시에도 반영해 다음 진입 시 옛 등고선 간격으로 복원되지 않게 한다.
|
||||||
|
const cached = deps.latestSource.read();
|
||||||
|
if (cached) {
|
||||||
|
deps.latestSource.write({
|
||||||
|
...cached,
|
||||||
|
surface_params: { ...cached.surface_params, contour_interval_m: interval },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error instanceof Error ? error.message : "등고선 조회에 실패했습니다.", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ④ 3D 지형 — 화면을 잡지 않고 뒤에서 올린다. 실패해도 나머지는 그대로 쓴다. */
|
||||||
|
async function loadTerrain(): Promise<void> {
|
||||||
|
const surface = state.confirmedSurface;
|
||||||
|
const current = state.latest;
|
||||||
|
if (!surface || !current || deps.host.disposed()) return;
|
||||||
|
try {
|
||||||
|
// 범위는 ②에서 받아 둔 확정 응답에 이미 들어 있다 — 다시 부르지 않는다.
|
||||||
|
if (!surface.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다.");
|
||||||
|
await viewer.loadSurface(
|
||||||
|
projectId,
|
||||||
|
surface.model_id as number,
|
||||||
|
current.surface_params.method,
|
||||||
|
current.surface_params.smooth,
|
||||||
|
current.surface_params.contour_interval_m,
|
||||||
|
toBounds(surface.bounds),
|
||||||
|
);
|
||||||
|
if (deps.host.disposed()) return; // 올리는 사이 페이지를 떠났다.
|
||||||
|
deps.sections().renderLatest(current); // 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다.
|
||||||
|
deps.sections().redrawStationLines();
|
||||||
|
showToast("3D 지형 준비 완료", "success");
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error instanceof Error ? error.message : "3D 지형을 불러오지 못했습니다.", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { viewCallbacks, applyContours, loadTerrain };
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* design_view_profile_sections.ts
|
||||||
|
* 종단 보기의 **노선 · 종단면 그리기** — 최신 노선 · 측점선 · 종단면 자료(불러오기 · 그리기 ·
|
||||||
|
* 옛 측점 이관). `design_view_profile.ts` 가 700줄을 넘어 뗀 조각(345-1). 상태는 본체가 쥔
|
||||||
|
* `ProfileViewState` 한 벌을 같이 본다.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { showToast } from "@ui/ui_template_elements";
|
||||||
|
import type { SurfaceConfirmedResponse } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||||
|
import type { RouteLatestResponse } from "../../B05_Profile/B05_Profile_Api_Fetch";
|
||||||
|
import type { RoutePanel } from "../../B05_Profile/B05_Profile_UI_Panel";
|
||||||
|
import type { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_Profile_Panel";
|
||||||
|
import type { createStructuresBridge } from "../../B05_Profile/B05_Profile_UI_Page_Structures";
|
||||||
|
import type { RouteViewer } from "../../B05_Profile/B05_Profile_UI_Viewer";
|
||||||
|
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||||
|
import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
|
||||||
|
import { migrateLegacyStations } from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||||
|
import { loadCorridorIfFresh } from "../../B05_Profile/B05_Profile_UI_Corridor";
|
||||||
|
import { stationLinesOf } from "../../B05_Profile/B05_Profile_UI_Page_Stations";
|
||||||
|
import { restorePoints, type RoadWidths } from "../../B05_Profile/B05_Profile_UI_Page_Helpers";
|
||||||
|
import { panelValuesOf } from "../../B05_Profile/B05_Profile_UI_Page_Latest";
|
||||||
|
|
||||||
|
/** 종단 보기 본체 · 조각이 함께 보는 값 한 벌. */
|
||||||
|
export interface ProfileViewState {
|
||||||
|
/** 확정 지표면 — 모델 id 와 3D 로딩에 필요한 범위만 들고 있는다(2026-09-06 호출 정리). */
|
||||||
|
confirmedSurface: SurfaceConfirmedResponse | null;
|
||||||
|
latest: RouteLatestResponse | null;
|
||||||
|
roadWidths: RoadWidths;
|
||||||
|
detail: SectionDetailResponse | null;
|
||||||
|
/** [예상형상] 표시 상태 — 측점 바를 계획고 위로 올릴지 판단한다(패널 기본 ON). */
|
||||||
|
corridorVisible: boolean;
|
||||||
|
routeReady: boolean;
|
||||||
|
restoring: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileSectionsDeps {
|
||||||
|
projectId: string;
|
||||||
|
state: ProfileViewState;
|
||||||
|
viewer: RouteViewer;
|
||||||
|
panel: () => RoutePanel;
|
||||||
|
profilePanel: () => ReturnType<typeof createRouteProfilePanel>;
|
||||||
|
bridge: () => ReturnType<typeof createStructuresBridge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 구 구조물 측점 이관을 이미 시도한 노선 — 탭 수명 동안 유지한다. */
|
||||||
|
const migratedRoutes = new Set<string>();
|
||||||
|
|
||||||
|
export function createProfileSections(deps: ProfileSectionsDeps) {
|
||||||
|
const { projectId, state, viewer } = deps;
|
||||||
|
|
||||||
|
function restorePanel(next: RouteLatestResponse): void {
|
||||||
|
deps.panel().restore(panelValuesOf(next));
|
||||||
|
viewer.markers.setPoints(restorePoints(next));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStationLines(detail: SectionDetailResponse): void {
|
||||||
|
const lines = stationLinesOf({
|
||||||
|
detail,
|
||||||
|
irregular: deps.bridge().irregularStations(),
|
||||||
|
routePoints: state.latest?.route_points ?? [],
|
||||||
|
liveSamples: deps.profilePanel().alignmentSamples(),
|
||||||
|
corridorVisible: state.corridorVisible,
|
||||||
|
});
|
||||||
|
viewer.renderStationLines(lines, state.roadWidths[deps.panel().values().gradeClass] / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 지금 종단면 자료로 측점선만 다시 그린다(자료가 없으면 그대로). */
|
||||||
|
function redrawStationLines(): void {
|
||||||
|
if (state.detail) renderStationLines(state.detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSections(detail: SectionDetailResponse, routeId?: number): void {
|
||||||
|
const panel = deps.panel();
|
||||||
|
const profilePanel = deps.profilePanel();
|
||||||
|
state.detail = detail;
|
||||||
|
profilePanel.render(detail, panel.values().stationInterval ?? undefined, routeId);
|
||||||
|
// 저장된 계획선의 정책은 만들 당시 기준이라 지금 설정(임도 종류·설계속도·지형)과
|
||||||
|
// 다를 수 있다 — 그릴 때마다 현재 기준으로 맞춘다(2026-08-19).
|
||||||
|
profilePanel.setGradeLimit(panel.gradeLimitPct());
|
||||||
|
profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance);
|
||||||
|
profilePanel.setStationDisplay(panel.stationDisplayOffset());
|
||||||
|
profilePanel.setIrregularStations(deps.bridge().irregularStations());
|
||||||
|
renderStationLines(detail);
|
||||||
|
// 예상형상 코리도 — 저장본이 지금 정본과 맞을 때만 올린다. 낡았으면 [3D 업데이트] 대기
|
||||||
|
// 표시만 켠다(2026-09-06 실측: 받아놓고 버리는 데다 다시 만드느라 전환이 16.7초까지 갔다).
|
||||||
|
const routePoints = state.latest?.route_points ?? [];
|
||||||
|
if (routePoints.length > 1) {
|
||||||
|
void loadCorridorIfFresh(
|
||||||
|
viewer,
|
||||||
|
projectId,
|
||||||
|
routeId ?? state.latest?.route?.id,
|
||||||
|
detail,
|
||||||
|
routePoints,
|
||||||
|
profilePanel.alignmentSamples() ?? undefined,
|
||||||
|
).then((fresh) => panel.setCorridorPending(!fresh));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreSections(routeId: number): Promise<void> {
|
||||||
|
const profilePanel = deps.profilePanel();
|
||||||
|
let detail: SectionDetailResponse;
|
||||||
|
profilePanel.setLoading("종단면 자료를 불러오는 중…");
|
||||||
|
try {
|
||||||
|
// 공유 캐시 — 횡단 보기가 이미 받아 뒀으면 같은 객체를 재사용해 두 보기가 같은 값을 본다.
|
||||||
|
detail = await loadSectionDetail(projectId, routeId);
|
||||||
|
} catch {
|
||||||
|
// 종횡단 데이터 자체가 없는 경우(생성 실패·최초 진입)는 빈 안내로 둔다.
|
||||||
|
state.detail = null;
|
||||||
|
profilePanel.setLoading(null);
|
||||||
|
profilePanel.clear();
|
||||||
|
viewer.renderStationLines([], 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
profilePanel.setLoading(null);
|
||||||
|
try {
|
||||||
|
renderSections(detail, routeId);
|
||||||
|
// 구 확정분에 남은 비정규 측점(자유 텍스트)을 구조물 정본으로 1회 이관한다 — 멱등이지만
|
||||||
|
// 탭 수명 동안 노선마다 한 번만 시도한다(2026-08-17 · 09-06 실측).
|
||||||
|
const legacy = detail.longitudinal.stations
|
||||||
|
.filter((station) => station.kind === "irregular")
|
||||||
|
.map((station) => ({
|
||||||
|
chainage_m: station.chainage_m,
|
||||||
|
structure: station.structure ?? "",
|
||||||
|
}));
|
||||||
|
const migrateKey = `${projectId}:${routeId}`;
|
||||||
|
if (legacy.length && !migratedRoutes.has(migrateKey)) {
|
||||||
|
migratedRoutes.add(migrateKey);
|
||||||
|
void migrateLegacyStations(projectId, legacy)
|
||||||
|
.then((result) => {
|
||||||
|
if (result.migrated > 0) {
|
||||||
|
showToast(`구 구조물 측점 ${result.migrated}건을 구조물 목록으로 옮겼습니다.`);
|
||||||
|
return deps
|
||||||
|
.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 {
|
||||||
|
state.latest = next;
|
||||||
|
state.routeReady = Boolean(next.route && next.route_points.length > 1);
|
||||||
|
deps.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;
|
||||||
|
}>) ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
restorePanel,
|
||||||
|
renderStationLines,
|
||||||
|
redrawStationLines,
|
||||||
|
renderSections,
|
||||||
|
restoreSections,
|
||||||
|
renderLatest,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user