Files
Aislo/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts
T
eomsangdonandClaude Opus 5.5 67de77c48c feat(B05 노선 추천 창): 측점 맞춤 → 종단 반영 단추 · [재계산] 늘 켜짐 + recompute · [적용] 균형 종단 = 단추 값 (421)
- 「종단 반영」 단추(기본 켬 · 다섯 갈래 · 누르면 바로 셈) · 요청에 with_profile 늘 실음 · 답 with_profile 먼저
- [재계산] = 입력 오류 · 셈 중 빼고 늘 켜짐 · recompute: true 보냄(서버 420 짝)
- [적용] · [저장] 의 initial 에 with_profile · 다시 열 때 단추 되살림 · 옛 기록은 갈래로
- 맞추기 전 선(unsnapped) 화면에서 걷음 · 도움말 글(측점 맞춤 늘 · 종단 반영 · 재계산)
- 시험 test_421_recommend_profile · 383_3 · 383_1 · 351_2 고쳐 씀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-18 12:32:41 +09:00

223 lines
9.5 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_RouteEdit_Initial.ts
* 계획노선 편집의 **노선 추천 이음**(계획서 20 · 23-2) — 좌측 「노선 추천」 칸(345-4) [노선 추천] 단추 →
* 모달(`_Recommend`) · 「현재 추천: ○○」 · [저장]에 실을 지금 추천. 칸 요소는
* 밖(`_Panel_Recommend`)에서 받는다.
*
* 모달의 알약이 서버에 갈래를 묻고(`/route/initial`) 미리 보기만 한다. [적용]을 눌러야 받은 노드를
* **편집값에 싣고**(되돌리기 한 걸음) 캐시(`routeedit-initial`)에 적는다. 칸 [초기화]는 없음(418) —
* 되돌림은 좌측 아래 [초기화](`design_reset` · 마지막 [적용] 노선). 손으로 고친 노선의 다시 계산은
* 페이지 [저장]에서(`_Apply` · 346-3) —
* 추천 [적용]만 그 자리에서 서버 노선을 `as_initial` 로 저장한다(초기값 = 추천 노선 · 사용자 확정 · 잠금 막 ·
* 끝나면 페이지 다시 세움). 비교값 · 기준값은 서버가 준 것을 보이기만 한다.
*
* 공통 제약 네 칸(종단기울기 % · 최소 반지름 m · 계류 이격 m · 지반 고름 m, 계획서 20 확정)
* — 비우면 서버 기본값을 쓴다. 채운 칸만 요청에 실어 보내고, 그 「채운 칸」만 캐시 ·
* `initial_choice.json` 에 남아 다시 열 때 되살아난다. 빈 칸의 placeholder 는 그 칸을 처음 비운 채
* 요청했을 때 서버가 답으로 준 값(진짜 기본값)을 기억해 둔 것이다.
* ========================================================================== */
import { fetchRoutePlan, type RoutePlanResponse } from "./B05_Profile_Api_Replan";
import {
fetchPrecomputeProgress,
fetchRouteInitial,
type RouteInitialCriteria,
type RouteInitialLogic,
type RouteInitialWeights,
} from "./B05_Profile_Api_RouteInitial";
import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
import {
choiceLabel,
choiceWithProfile,
DEFAULT_WEIGHTS,
NO_WEIGHT_LOCKS,
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";
import { saveRecommendedRoute } from "./B05_Profile_UI_RouteEdit_Apply";
export interface InitialPanelParams {
/** 단추 · 글 요소 — 좌측 「노선 추천」 칸 것(345-4). */
controls: {
recommendButton: HTMLButtonElement;
nowLine: HTMLElement;
};
projectId: string;
/** 지금 편집기 노드 — 추천 창이 「지금 노선」으로 같이 그린다. */
planned: () => Array<[number, number]>;
/** `/route/plan` 이 준 최소 R — 서버 갈래 기준값이 오기 전 보일 값. */
planMinRadiusM: () => number;
/** 받은 노선을 편집값으로 싣는다 — 되돌리기 한 걸음으로 쌓인다. */
apply: (snapshot: RouteEditSnapshot, message: string) => void;
}
export interface InitialPanel {
/** 노선을 읽었다 — 창을 연 편집값 · 서버가 적어 둔 현재 초기값(`current_initial`). */
ready: (opening: RouteEditSnapshot, server?: unknown) => void;
/** [저장]에 실어 보낼 지금 추천. */
current: () => {
mode: RouteInitialLogic;
weights: RouteInitialWeights | null;
criteria: RouteInitialCriteria | null;
with_profile: boolean;
};
/** 지금 노선이 종단을 반영하는가 — 지금 추천의 「종단 반영」 값(421 · 옛 기록이면 갈래로).
* 종단 띠 · [확인]이 함께 본다(추천을 손으로 고쳤어도 그 값대로). */
withProfile: () => boolean;
/** 지금 추천 — 편집 걸음(사진)에 실어 둔다. 되돌리기 · 다시하기가 `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 { projectId } = params;
const { recommendButton, nowLine } = params.controls;
/** 창을 열 때 캐시에 있던 추천 — [취소]가 돌아갈 자리. */
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 busy = false;
/** 비중 · 고정 — 모달을 닫았다 열어도 이 편집 창이 사는 동안 남는다. */
const weightMemory = {
weights: null as RouteInitialWeights | null,
locks: { ...NO_WEIGHT_LOCKS },
};
/** `/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;
}
/** 모달 알약 하나 — 서버에 갈래를 묻고 편집값 한 벌로 펴 돌려준다. 싣는 것은 [적용] 뒤. */
async function request(
logic: RouteInitialLogic,
weights: RouteInitialWeights | null,
criteria: RouteInitialCriteria | null,
withProfile: boolean,
recompute: boolean,
): Promise<InitialChoice> {
const response = await fetchRouteInitial(projectId, {
mode: logic,
...(weights ? { weights } : {}),
...(criteria ? { criteria } : {}),
with_profile: withProfile,
...(recompute ? { recompute: true } : {}),
});
return normalizeRouteInitial(response, logic, weights, criteria, withProfile);
}
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) },
weightMemory,
logic: (choice ?? base).logic,
// 적용한 추천의 값 · 없으면 켬(421 — 옛 기록의 예상노선 추적도 켬에서 시작)
withProfile: choice?.withProfile ?? base.withProfile ?? true,
planMinRadiusM: params.planMinRadiusM(),
expected: (plan?.expected ?? []).map(([x, y]) => [x, y]),
current: params.planned().map(([x, y]) => [x, y]),
request,
progress: () => fetchPrecomputeProgress(projectId),
onApply: (picked) => {
choice = picked;
writeInitialChoice(projectId, choice);
render();
params.apply(
copy(picked.snapshot),
`노선 추천 「${choiceLabel(picked.logic, picked.weights)}」 적용 — 서버에 저장합니다.`,
);
// 초기값 = 추천 노선(346-3) — 바로 저장 · 실패하면 평면에 실린 채 남아 [저장]이 다시 보냄.
void saveRecommendedRoute(
projectId,
picked.snapshot,
{
mode: picked.logic,
weights: picked.weights,
criteria: picked.criteriaOverrides,
with_profile: choiceWithProfile(picked), // initial_choice 에 남김(420)
},
choiceWithProfile(picked),
);
},
});
})();
});
render();
return {
ready(snapshot, server) {
opening = copy(snapshot);
// 「현재 추천」은 서버가 [확인] 때 적어 둔 것이 정본이다 — 캐시가 다르면(확인 없이 닫음)
// 캐시를 버린다.
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,
with_profile: choiceWithProfile(now),
};
},
withProfile: () => choiceWithProfile(choice ?? base),
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),
};
}