- 옮김 목록(ROUTE_CARRY_STATES)에 uphill 줄 — 노선 저장 · 측점 갈아끼움 때 새 측점으로 - 노선을 저장했으면 상단측 차례 건너뜀 → carryAfterRouteSave 가 옮긴 뒤 보냄(옛 누가거리로 엉뚱한 측점에 안 얹힘) - 새 노선에 같은 측점이 없으면 뺌 · 「상단측 N곳 … 뺐습니다」 알림 · 노선 밖 고침 수에도 - 시험 test_443_2_uphill_carry.py · 346-3 · 저장 차례 시험 기대값 맞춤 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
245 lines
12 KiB
TypeScript
245 lines
12 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, readState } 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 {
|
|
carryChainageStates,
|
|
carryStationDrafts,
|
|
useStationKeys,
|
|
} from "../B06_Section/B06_Section_Station_Keys";
|
|
import { withCarriedEdits, withDraftAdjusts } from "../B06_Section/B06_Section_Section_Adjusts";
|
|
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
|
import { pipeChainageOf } from "../A00_Common/design/design_pipe_ids";
|
|
import { clearRouteLatestCache, flushUphillOverrides } from "./B05_Profile_Api_Fetch";
|
|
import {
|
|
replanRoute,
|
|
type RouteReplanResponse,
|
|
type RouteSaveBody,
|
|
} from "./B05_Profile_Api_Replan";
|
|
import type { SnapSetting } from "./B05_Profile_UI_RouteEdit_SnapStep";
|
|
|
|
type Vertex = [number, number];
|
|
type RouteSettings = SnapSetting & { terrain: string | null };
|
|
type RouteNodes = { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array<number | null> } & {
|
|
curveLock?: RouteDraft["curveLock"];
|
|
curveArc?: 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) · 간격(390-5) — 곡선표에 적혀 측점 셈이 읽음. */
|
|
snap: () => SnapSetting;
|
|
/** 초기노선 갈래 — 저장에 지금 추천을 싣고, 결과 캐시를 비운 뒤 다시 적는다. */
|
|
initial: {
|
|
current: () => unknown;
|
|
keep: () => void;
|
|
};
|
|
/** 저장이 끝나면 페이지를 새로고침 없이 다시 세운다. */
|
|
onApplied: () => void | Promise<void>;
|
|
}
|
|
|
|
interface RouteSource {
|
|
pending: () => Promise<RouteSaveBody | null>;
|
|
settings: () => RouteSettings;
|
|
saved: () => void;
|
|
rebuild: () => void | Promise<void>;
|
|
}
|
|
|
|
const sources = new Map<string, RouteSource>();
|
|
|
|
/** 노드 · 곡선 편집값 → 계약 `RouteDraft`(R · 길이 고정도 실음 — 서버가 곡선표 `edits` 에 둠 · 430). */
|
|
export function routeDraftOf(nodes: RouteNodes, settings: RouteSettings): 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((_, index) => nodes.curveLock?.[index] ?? null),
|
|
curveArc: nodes.planned.map((_, index) => nodes.curveArc?.[index] ?? null),
|
|
stationSnap: settings.stationSnap,
|
|
stationSnapStep: settings.stationSnapStep ?? null,
|
|
terrain: settings.terrain,
|
|
};
|
|
}
|
|
|
|
/** 편집기를 등록부에 건다 — 같은 프로젝트에 새 편집기가 서면 그쪽으로 바뀐다. */
|
|
export function bindRouteApply(params: RouteApplyParams): void {
|
|
const settings = () => ({ ...params.snap(), 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(),
|
|
// 손으로 고친 노선 = 계획고 옮김만(344-2 · 349-0v) — 균형 종단은 추천 [적용](`saveRecommendedRoute`)만.
|
|
};
|
|
},
|
|
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> {
|
|
pipesBeforeSave.set(projectId, await savedPipes(projectId));
|
|
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}`,
|
|
];
|
|
const dropped = (saved.cross_dropped ?? 0) + outside;
|
|
if (dropped) parts.push(`노선 밖 고침 ${dropped} 건`);
|
|
if (saved.drainage_stale) parts.push("배수유역 낡음");
|
|
return `노선 저장 — ${parts.join(" · ")}`;
|
|
}
|
|
|
|
type SavedPipe = { id?: string; chainage_m: number };
|
|
/** 노선 저장 직전 저장 관(id · 옛 누가거리) — 저장 뒤 같은 id 의 새 자리로 조절을 옮김(초안으로 안 따라간 고침 · 417). */
|
|
const pipesBeforeSave = new Map<string, SavedPipe[]>();
|
|
|
|
const savedPipes = (projectId: string): Promise<SavedPipe[]> =>
|
|
fetchDetailPipePoints(projectId)
|
|
.then((res) => res.pipe_points)
|
|
.catch(() => []);
|
|
|
|
/**
|
|
* 노선 저장 뒤 세션 횡단 초안(옛 노선 측점 열쇠 · 누가거리 열쇠 조절 · 417) → 새 노선으로 넘기고 그 고침을 저장 —
|
|
* 초안을 통째로 지우지 않는다. 사라진 측점 고침은 버림(수를 돌려줌 · 서버 `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);
|
|
// 관 열쇠 → 새 자리 — 세션 id(`pipe-s3`) = 관 장부 · 저장 관 id(`p8017`) = 서버가 옮긴 관
|
|
const saved = await savedPipes(projectId);
|
|
const before = pipesBeforeSave.get(projectId) ?? [];
|
|
pipesBeforeSave.delete(projectId);
|
|
const newAt = (id: string | undefined): number | null =>
|
|
saved.find((pipe) => id !== undefined && pipe.id === id)?.chainage_m ?? null;
|
|
const pipeAt = (key: string): number | null => {
|
|
const id = key.startsWith("pipe:") ? key.slice(5) : null;
|
|
if (!id) return null;
|
|
return id.startsWith("pipe-") ? pipeChainageOf(id, projectId) : newAt(id);
|
|
};
|
|
// 옛 저장 관 자리에 남은 고침(초안으로 안 따라감) = 같은 관 id 의 새 자리
|
|
const movedAt = (chainageM: number): number | null =>
|
|
newAt(before.find((pipe) => Math.abs(pipe.chainage_m - chainageM) < 0.01)?.id);
|
|
const uphillCount = (): number => Object.keys(readState("uphill", projectId) ?? {}).length;
|
|
const uphillBefore = uphillCount();
|
|
const outside =
|
|
carryStationDrafts(projectId, fromRouteId, toRouteId, { quiet: true }) +
|
|
carryChainageStates(projectId, fromRouteId, toRouteId, { keyAt: pipeAt, movedAt });
|
|
// 상단측 = 새 측점으로 옮긴 것만 횡단 패치 앞에 보냄(443-2 · 옛 누가거리로 엉뚱한 측점에 안 얹힘)
|
|
const uphillDropped = uphillBefore - uphillCount();
|
|
if (uphillDropped > 0)
|
|
showToast(`상단측 ${uphillDropped}곳 — 새 노선에 같은 측점이 없어 뺐습니다.`, "info");
|
|
await flushUphillOverrides(projectId);
|
|
// 옮긴 고침을 새 상세 설계 칸에 — 같은 [저장]의 횡단 패치가 실음(사면 쉬프트 · 조절 · 소단 깊이 · 417)
|
|
withDraftAdjusts(detail, projectId, toRouteId);
|
|
withCarriedEdits(detail, projectId, toRouteId);
|
|
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, {
|
|
// 383-1 — 추천 선은 곡선 MC 를 일반측점에 맞춘 선 → [측점 스냅] 켬 · 측점 No.(390-5)로 저장
|
|
draft: routeDraftOf(nodes, { ...settings, stationSnap: true, stationSnapStep: null }),
|
|
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();
|
|
}
|
|
}
|