Files
Aislo/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts
T
eomsangdonandClaude Opus 5.5 281c71b55b refactor(logic): 348-1 순수 계산 TS 49 파일 <페이지>/logic/ 로 옮김 — 이름 앞머리 그대로 · 동작 고침 없음
- 옮김: B04 2 · B05 23 · B06 11 · A00_Common/design 13 (9,299 줄) · 이미 logic/ 에 있던 16 파일 그대로
- 안 옮김: 묶음 입구(package.json build:*) · 파이썬 · 순수지만 계산 아닌 8 (보기 결선 · 콜백 · API 타입 · 갈아끼움 알림판)
- import 266 줄(142 파일) · 시험 · 주석 · 짝 표시 문자열 경로 고침 · B05 · B06 CLAUDE.md 짝 줄 logic/
- 시험 도우미 새 경로: 가짜 require 열쇠 · B05()/B06() 부름 · 형제 import 자리 · tsc 묶어 돌리는 시험 · 정규식 · rglob
- design_view_cross 줄 한도 시험 685 → 688 (긴 import 경로 줄바꿈 3줄)
- 확인: typecheck 0 · 서버 묶음 넷(corridor · server-calc · b07-extent · b07-note) 다시 지어 코드 같음(//#region 경로 주석만 다름) · 관련 시험 270 파일 — 남은 실패 19 는 옮기기 전과 같음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-16 13:44:50 +09:00

202 lines
9.1 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_RouteEdit_Apply.ts
* 노선 편집(평면)의 **노선 저장 이음**(346-3) — [노선 반영] 단추는 없앰.
*
* · 페이지 [저장](`design_save`)이 맨 앞에서 `pendingRouteSave` 로 지금 노선 초안을 받아 `saveRouteDraft`
* (`POST /route/replan` · draft = 계약 `RouteDraft`)로 보낸다 — 노선이 마지막 저장 그대로면 null(안 보냄).
* · 저장 뒤 `carryAfterRouteSave` 가 세션 횡단 초안을 측점 열쇠로 새 노선에 넘기고(`carryStationDrafts`)
* 넘긴 고침을 저장 · 결과 한 줄 · 페이지는 새로고침 없이 다시 세운다(`rebuildAfterRouteSave`).
* · 추천 [적용] = `saveRecommendedRoute` — `as_initial: true` 로 바로 저장(초기값 = 추천 노선 · 사용자 확정).
* 편집기가 살아 있는 동안 여기 등록부에 「지금 초안」 · 「다시 세우기」를 건다(프로젝트 하나에 하나).
* 실패하면 서버가 옛 노선으로 되살리고 화면 초안은 그대로 남는다(오류 한 줄은 부르는 쪽).
* ========================================================================== */
import { clearDrafts, clearResults } from "../A00_Common/b_page_state";
import type { RouteDraft } from "../A00_Common/design/logic/design_pipeline_types";
import { useDesignStore } from "../A00_Common/design/design_store";
import { currentRoute, remountRoute } from "../A00_Common/router";
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
import {
invalidateSectionDetail,
loadSectionDetail,
saveCachedCrossPatches,
} from "../B06_Section/B06_Section_Section_Store";
import { carryStationDrafts, useStationKeys } from "../B06_Section/B06_Section_Station_Keys";
import { clearRouteLatestCache } from "./B05_Profile_Api_Fetch";
import {
replanRoute,
type RouteReplanResponse,
type RouteSaveBody,
} from "./B05_Profile_Api_Replan";
type Vertex = [number, number];
type RouteNodes = { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array<number | null> };
export interface RouteApplyParams {
/** 옛 [노선 반영] 자리(단추 · 대기 막) — 346-3 부터 안 씀(대기는 페이지 잠금 막 하나). */
overlay?: HTMLElement;
busy?: HTMLElement;
projectId: string;
/** 지금 편집값 — 저장하는 순간에 읽는다. */
nodes: () => RouteNodes;
/** 마지막 저장 뒤 노선이 바뀌었나 — 안 바뀌었으면 보내지 않는다. */
changed: () => boolean;
/** 설계 지형으로 보낼 분석 지형(PLAN 133-2) — 고르기가 안 보이면 null(지금 설계 지형 그대로). */
terrain: () => string | null;
/** [측점 스냅] 켬끔(요구 224-2) — 곡선표에 적혀 측점 셈이 읽음. */
stationSnap: () => boolean;
/** 초기노선 갈래 — 저장에 지금 추천을 싣고, 결과 캐시를 비운 뒤 다시 적는다. */
initial: {
current: () => unknown;
keep: () => void;
};
/** 종단 띠 — 응답을 기다려 띠에 보인 계획선(균형 종단 · 지반고)을 그대로 적용한다(PLAN 75-1). */
profileShown: () => Promise<boolean>;
/** 저장이 끝나면 페이지를 새로고침 없이 다시 세운다. */
onApplied: () => void | Promise<void>;
}
interface RouteSource {
pending: () => Promise<RouteSaveBody | null>;
settings: () => { stationSnap: boolean; terrain: string | null };
saved: () => void;
rebuild: () => void | Promise<void>;
}
const sources = new Map<string, RouteSource>();
/** 노드 · 곡선 편집값 → 계약 `RouteDraft`(붙든 값은 서버가 안 씀 · 칸 길이만 맞춤). */
export function routeDraftOf(
nodes: RouteNodes,
settings: { stationSnap: boolean; terrain: string | null },
): RouteDraft {
return {
rev: 0,
nodes: nodes.planned.map(([x, y]): Vertex => [x, y]),
curveOn: nodes.planned.map((_, index) => nodes.curveOn[index] !== false),
curveRadius: nodes.planned.map((_, index) => nodes.curveRadius[index] ?? null),
curveLock: nodes.planned.map(() => null),
curveArc: nodes.planned.map(() => null),
stationSnap: settings.stationSnap,
terrain: settings.terrain,
};
}
/** 편집기를 등록부에 건다 — 같은 프로젝트에 새 편집기가 서면 그쪽으로 바뀐다. */
export function bindRouteApply(params: RouteApplyParams): void {
const settings = () => ({ stationSnap: params.stationSnap(), terrain: params.terrain() });
sources.set(params.projectId, {
pending: async () => {
const nodes = params.nodes();
if (nodes.planned.length < 2 || !params.changed()) return null;
return {
draft: routeDraftOf(nodes, settings()),
initial: params.initial.current(),
// 띠 응답 전이면 기다린다 — 서버는 같은 노선 · 같은 셈으로 띠 계획선을 다시 낸다.
balanced_profile: await params.profileShown(),
};
},
settings,
saved: () => params.initial.keep(),
rebuild: params.onApplied,
});
}
/** 지금 보낼 노선 초안 — 편집기가 있으면 그것(안 바뀌었으면 null) · 없으면 가게 `routeDraft`. */
export async function pendingRouteSave(projectId: string): Promise<RouteSaveBody | null> {
const source = sources.get(projectId);
if (source) return source.pending();
const draft = useDesignStore(projectId, null).get("routeDraft");
return draft && draft.nodes.length >= 2 ? { draft } : null;
}
/** 노선 초안 저장 — 실패하면 throw(서버가 되살림 · 화면 초안 그대로). 새 노선 번호 · 결과를 돌려줌. */
export async function saveRouteDraft(
projectId: string,
body: RouteSaveBody,
): Promise<RouteReplanResponse> {
const saved = await replanRoute(projectId, body);
useDesignStore(projectId, null).set("routeDraft", null);
// 노선이 바뀌면 조회 결과 캐시는 옛 노선 것 — 추천은 새 노선의 출발점이라 다시 적는다.
clearResults(projectId);
sources.get(projectId)?.saved();
return saved;
}
/** 저장 결과 한 줄 — 관 지킴 · 새 교차 관 · 구조물 옮김 · 멀어짐 · 노선 밖 고침 · 배수유역 낡음. */
export function routeSaveSummary(saved: RouteReplanResponse, outside = 0): string {
const parts = [
`관 지킴 ${saved.pipes?.kept ?? 0}`,
`새 교차 관 ${saved.pipes?.crossing ?? 0}`,
`구조물 옮김 ${saved.structures?.moved ?? 0}`,
`멀어짐 ${saved.structures?.far ?? 0}`,
];
if (saved.pipes?.concave) parts.splice(2, 0, `오목 보충 관 ${saved.pipes.concave}`);
const dropped = (saved.cross_dropped ?? 0) + outside;
if (dropped) parts.push(`노선 밖 고침 ${dropped} 건`);
if (saved.drainage_stale) parts.push("배수유역 낡음");
return `노선 저장 — ${parts.join(" · ")}`;
}
/**
* 노선 저장 뒤 세션 횡단 초안(옛 노선 측점 열쇠) → 새 노선으로 넘기고 그 고침을 저장 — 초안을 통째로
* 지우지 않는다. 사라진 측점 고침은 버림(수를 돌려줌 · 서버 `cross_dropped` 와 한 줄로).
*/
export async function carryAfterRouteSave(
projectId: string,
fromRouteId: number,
toRouteId: number,
): Promise<number> {
invalidateSectionDetail(projectId, toRouteId);
const detail = await loadSectionDetail(projectId, toRouteId);
useStationKeys(projectId, toRouteId, detail);
const outside = carryStationDrafts(projectId, fromRouteId, toRouteId, { quiet: true });
await saveCachedCrossPatches(projectId, toRouteId);
return outside;
}
/** 저장 뒤 페이지를 새로고침 없이 다시 세운다 — 편집기가 걸어 둔 길(옛 노선 캐시 비움 포함) · 없으면 라우터. */
export async function rebuildAfterRouteSave(projectId: string): Promise<void> {
const source = sources.get(projectId);
if (source) return source.rebuild();
clearRouteLatestCache(projectId);
invalidateSectionDetail(projectId);
remountRoute(currentRoute());
}
/**
* 추천 [적용] — 받은 노드로 서버 노선을 바로 저장하고(`as_initial` · 초기값 = 추천 노선) 페이지를 다시
* 세운다. 잠금 막 = 「노선 재탐색」 자리 하나. 새 초기값에서 다시 시작하므로 초안은 비운다([초기화]와 같음).
* 실패하면 false — 서버가 옛 노선으로 되살렸고 화면 노선은 그대로.
*/
export async function saveRecommendedRoute(
projectId: string,
nodes: RouteNodes,
initial: unknown,
balancedProfile: boolean,
): Promise<boolean> {
const settings = sources.get(projectId)?.settings() ?? { stationSnap: false, terrain: null };
showLoadingOverlay();
try {
await saveRouteDraft(projectId, {
draft: routeDraftOf(nodes, settings),
initial,
balanced_profile: balancedProfile,
as_initial: true,
});
clearDrafts(projectId);
sources.get(projectId)?.saved();
showToast("추천 노선을 저장했습니다 — 새 초기값입니다.", "success");
await rebuildAfterRouteSave(projectId);
return true;
} catch (error) {
showToast(
`추천 노선 저장에 실패했습니다.${error instanceof Error ? ` ${error.message}` : ""}`,
"error",
);
return false;
} finally {
hideLoadingOverlay();
}
}