Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019BVZd1M6WUf1YEy9gbAE1G
216 lines
9.1 KiB
TypeScript
216 lines
9.1 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_UI_RouteEdit_Initial.ts
|
|
* 계획노선 편집 창의 **노선 추천 이음**(계획서 20 · 23-2) — 도구 막대 [노선 추천] 단추 →
|
|
* 모달(`_Recommend`) · [초기화] · 「현재 추천: ○○」 · [확인]에 실을 지금 추천.
|
|
*
|
|
* 모달의 알약이 서버에 갈래를 묻고(`/route/initial`) 미리 보기만 한다. [적용]을 눌러야 받은 노드를
|
|
* **편집값에 싣고**(되돌리기 한 걸음) 캐시(`routeedit-initial`)에 적는다. [초기화]는 서버를 다시
|
|
* 셈하지 않고 **전처리 때 만든 초기값 노선**(예상노선 추적)을 놓는다 — 그 노드는 `GET /route/plan` 의
|
|
* `initial_route` 한 곳에서 읽는다. 종단 · 배수유역 · 횡단 다시 계산은 여기서 안 돈다 —
|
|
* 노선이 바뀐 뒤 [확인]에서만(`_Apply`). 비교값 · 기준값은 서버가 준 것을 보이기만 한다.
|
|
*
|
|
* 공통 제약 네 칸(종단기울기 % · 최소 반지름 m · 계류 이격 m · 지반 고름 m, 계획서 20 확정)
|
|
* — 비우면 서버 기본값을 쓴다. 채운 칸만 요청에 실어 보내고, 그 「채운 칸」만 캐시 ·
|
|
* `initial_choice.json` 에 남아 다시 열 때 되살아난다. 빈 칸의 placeholder 는 그 칸을 처음 비운 채
|
|
* 요청했을 때 서버가 답으로 준 값(진짜 기본값)을 기억해 둔 것이다.
|
|
* ========================================================================== */
|
|
|
|
import { showToast } from "@ui/ui_template_elements";
|
|
import { fetchRoutePlan, type RoutePlanResponse } from "./B05_Profile_Api_Replan";
|
|
import {
|
|
fetchRouteInitial,
|
|
type RouteInitialCriteria,
|
|
type RouteInitialLogic,
|
|
type RouteInitialWeights,
|
|
} from "./B05_Profile_Api_RouteInitial";
|
|
import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
|
|
import {
|
|
choiceLabel,
|
|
DEFAULT_WEIGHTS,
|
|
initialRouteSnapshot,
|
|
normalizeRouteInitial,
|
|
readInitialChoice,
|
|
sameBase,
|
|
serverBase,
|
|
writeInitialChoice,
|
|
type CriteriaKey,
|
|
type InitialBase,
|
|
type InitialChoice,
|
|
} from "./B05_Profile_UI_RouteEdit_Initial_Model";
|
|
import { openRecommendModal } from "./B05_Profile_UI_RouteEdit_Recommend";
|
|
|
|
export interface InitialPanelParams {
|
|
overlay: HTMLElement;
|
|
projectId: string;
|
|
/** `/route/plan` 이 준 최소 R — 서버 갈래 기준값이 오기 전 보일 값. */
|
|
planMinRadiusM: () => number;
|
|
/** 받은 노선을 편집값으로 싣는다 — 되돌리기 한 걸음으로 쌓인다. */
|
|
apply: (snapshot: RouteEditSnapshot, message: string) => void;
|
|
}
|
|
|
|
export interface InitialPanel {
|
|
/** 노선을 읽었다 — 창을 연 편집값 · 「고친 노선인가」 · 서버가 적어 둔 현재 초기값(`current_initial`). */
|
|
ready: (opening: RouteEditSnapshot, edited: boolean, server?: unknown) => void;
|
|
/** [확인]에 실어 보낼 지금 추천. */
|
|
current: () => {
|
|
mode: RouteInitialLogic;
|
|
weights: RouteInitialWeights | null;
|
|
criteria: RouteInitialCriteria | null;
|
|
};
|
|
/** 지금 추천 — 편집 걸음(사진)에 실어 둔다. 되돌리기 · 다시하기가 `restoreTag` 로 되살린다. */
|
|
tag: () => InitialChoice | null;
|
|
restoreTag: (tag: unknown) => void;
|
|
/** [취소] — 이 창에서 바꾼 추천을 창을 열 때 것으로 되돌린다. */
|
|
cancel: () => void;
|
|
/** [확인] 뒤 결과 캐시를 비웠다 — 추천은 새 노선의 출발점이라 다시 적는다. */
|
|
keep: () => void;
|
|
}
|
|
|
|
const copy = (snapshot: RouteEditSnapshot): RouteEditSnapshot =>
|
|
JSON.parse(JSON.stringify(snapshot)) as RouteEditSnapshot;
|
|
|
|
export function createInitialPanel(params: InitialPanelParams): InitialPanel {
|
|
const { overlay, projectId } = params;
|
|
const recommendButton = overlay.querySelector<HTMLButtonElement>('[data-act="recommend"]')!;
|
|
const resetButton = overlay.querySelector<HTMLButtonElement>('[data-act="reset"]')!;
|
|
const nowLine = overlay.querySelector<HTMLElement>("[data-rec-now]")!;
|
|
|
|
/** 창을 열 때 캐시에 있던 추천 — [취소]가 돌아갈 자리. */
|
|
let openingChoice = readInitialChoice(projectId);
|
|
let choice: InitialChoice | null = openingChoice;
|
|
/** 노드 없이 이름만 아는 현재 추천 — 서버가 적어 둔 것(없으면 예상노선 추적). */
|
|
let base: InitialBase = { logic: "follow", weights: null, criteria: null };
|
|
/** 칸을 비운 채 처음 물었을 때 서버가 답한 값 — placeholder 로 보인다(진짜 기본값). */
|
|
const defaults: Partial<Record<CriteriaKey, number>> = {};
|
|
let opening: RouteEditSnapshot | null = null;
|
|
let edited = false;
|
|
let busy = false;
|
|
/** `/route/plan` 한 번 읽은 것 — 예상노선(지도 바탕) · 초기값 노선([초기화]). 창이 사는 동안 안 바뀐다. */
|
|
let planRead: Promise<RoutePlanResponse> | null = null;
|
|
|
|
function loadPlan(): Promise<RoutePlanResponse> {
|
|
planRead ??= fetchRoutePlan(projectId).catch((error) => {
|
|
planRead = null; // 다음에 다시 읽는다.
|
|
throw error;
|
|
});
|
|
return planRead;
|
|
}
|
|
|
|
function render(): void {
|
|
const now = choice ?? base;
|
|
nowLine.textContent = `현재 추천: ${choiceLabel(now.logic, now.weights)}`;
|
|
recommendButton.disabled = busy || !opening;
|
|
resetButton.disabled = busy || !opening;
|
|
}
|
|
|
|
/** 모달 알약 하나 — 서버에 갈래를 묻고 편집값 한 벌로 펴 돌려준다. 싣는 것은 [적용] 뒤. */
|
|
async function request(
|
|
logic: RouteInitialLogic,
|
|
weights: RouteInitialWeights | null,
|
|
criteria: RouteInitialCriteria | null,
|
|
): Promise<InitialChoice> {
|
|
const response = await fetchRouteInitial(projectId, {
|
|
mode: logic,
|
|
...(weights ? { weights } : {}),
|
|
...(criteria ? { criteria } : {}),
|
|
});
|
|
return normalizeRouteInitial(response, logic, weights, criteria);
|
|
}
|
|
|
|
recommendButton.addEventListener("click", () => {
|
|
void (async () => {
|
|
if (busy || !opening) return;
|
|
busy = true;
|
|
render();
|
|
const plan = await loadPlan().catch(() => null); // 지도 바탕만 못 깔 뿐 추천은 된다.
|
|
busy = false;
|
|
if (!recommendButton.isConnected) return; // 그 사이 창이 닫혔다.
|
|
render();
|
|
openRecommendModal({
|
|
criteria: choice?.criteriaOverrides ?? base.criteria,
|
|
defaults,
|
|
weights: { ...(choice?.weights ?? base.weights ?? DEFAULT_WEIGHTS) },
|
|
planMinRadiusM: params.planMinRadiusM(),
|
|
expected: (plan?.expected ?? []).map(([x, y]) => [x, y]),
|
|
current: opening!.planned.map(([x, y]) => [x, y]),
|
|
request,
|
|
onApply: (picked) => {
|
|
choice = picked;
|
|
writeInitialChoice(projectId, choice);
|
|
render();
|
|
params.apply(
|
|
copy(picked.snapshot),
|
|
`노선 추천 「${choiceLabel(picked.logic, picked.weights)}」 적용 — 다시 계산은 [확인]에서.`,
|
|
);
|
|
},
|
|
});
|
|
})();
|
|
});
|
|
|
|
// [초기화] — 재계산 없이 전처리 때 만든 초기값 노선(예상노선 추적)을 다시 놓는다.
|
|
resetButton.addEventListener("click", () => {
|
|
void (async () => {
|
|
if (busy || !opening) return;
|
|
busy = true;
|
|
render();
|
|
try {
|
|
const plan = await loadPlan().catch(() => null);
|
|
// 서버가 초기값 노선을 못 주면, 고친 적 없는 노선일 때만 창을 연 노선이 곧 초기값이다.
|
|
const snapshot = initialRouteSnapshot(plan?.initial_route) ?? (edited ? null : opening);
|
|
if (!snapshot) {
|
|
showToast("전처리 초기값 노선을 읽지 못했습니다.", "error");
|
|
return;
|
|
}
|
|
if (!resetButton.isConnected) return;
|
|
choice = {
|
|
logic: "follow",
|
|
weights: null,
|
|
snapshot: copy(snapshot),
|
|
metrics: null,
|
|
criteria: null,
|
|
criteriaOverrides: null,
|
|
};
|
|
writeInitialChoice(projectId, choice);
|
|
params.apply(
|
|
copy(snapshot),
|
|
"초기값(예상노선 추적)으로 되돌렸습니다 — 다시 계산은 [확인]에서.",
|
|
);
|
|
} finally {
|
|
busy = false;
|
|
if (resetButton.isConnected) render();
|
|
}
|
|
})();
|
|
});
|
|
render();
|
|
|
|
return {
|
|
ready(snapshot, wasEdited, server) {
|
|
opening = copy(snapshot);
|
|
edited = wasEdited;
|
|
// 「현재 추천」은 서버가 [확인] 때 적어 둔 것이 정본이다 — 캐시가 다르면(확인 없이 닫음)
|
|
// 캐시를 버린다.
|
|
if (server) {
|
|
base = serverBase(server);
|
|
if (choice && !sameBase(choice, base)) choice = null;
|
|
openingChoice = choice;
|
|
writeInitialChoice(projectId, choice);
|
|
}
|
|
render();
|
|
},
|
|
current: () => {
|
|
const now = choice ?? base;
|
|
const criteria = choice ? choice.criteriaOverrides : base.criteria;
|
|
return { mode: now.logic, weights: now.weights, criteria };
|
|
},
|
|
tag: () => choice,
|
|
restoreTag(tag) {
|
|
if (tag === undefined) return; // 추천을 안 실은 옛 걸음 — 그대로 둔다.
|
|
choice = tag as InitialChoice | null;
|
|
writeInitialChoice(projectId, choice);
|
|
render();
|
|
},
|
|
cancel: () => writeInitialChoice(projectId, openingChoice),
|
|
keep: () => writeInitialChoice(projectId, choice),
|
|
};
|
|
}
|