Files
Aislo/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts
T
eomsangdonandClaude Opus 5.5 1758ba0fb2 feat(B05): 초기노선 갈래 — 제약 못 지킨 구간 한 줄 · 상한 풂 안내 · 기울기 잴 지반 고름 칸
- 비교값 줄 아래 「제약 못 지킨 구간 N곳: No.9 16.4%(지형상 불가능) · …」(없으면 「모두 지킴」 · 5곳 넘으면 「외 N곳」 · 풍선에 전부와 사유)
- relaxed 면 「지형상 14.0% 로 못 이어져 상한을 29.1% 로 풂」(경고 색)
- 공통 제약 줄에 서버 grade_smooth_m 을 「기울기 잴 지반 5m 고름」으로
- 시험 — 글 만들기(test_b05_routeedit_initial) · 사본 왕복에서 목록 · 풂 · 고름 칸이 화면 줄에(test_b05_routeedit_initial_roundtrip)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sCeZz8z8YnbovsKgn7HK7
2026-09-29 10:42:14 +09:00

240 lines
11 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_RouteEdit_Initial.ts
* 계획노선 편집 창의 **초기노선 갈래**(계획서 20) — 로직 단추 셋 · 비중 슬라이더 · [초기화]
* · 「현재 초기값」 · 결과 비교값 · 공통 제약 기준값.
*
* 로직 단추를 누르면 서버가 노선을 새로 셈하고(`/route/initial`), 받은 노드를 **편집값에
* 싣는다**(되돌리기 한 걸음). 그 결과가 새 초기값이라 캐시(`routeedit-initial`)에 적고,
* [초기화]는 서버를 다시 부르지 않고 그 초기값으로 돌아간다. 종단 · 배수유역 · 횡단 다시
* 계산은 여기서 안 돈다 — 노선이 바뀐 뒤 [확인]에서만(`_Apply`).
* 비교값 · 기준값은 서버가 준 것을 보이기만 한다(계산 자리 = 서버 단독).
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import {
fetchRouteInitial,
ROUTE_INITIAL_MISSING,
type RouteInitialLogic,
type RouteInitialWeights,
} from "./B05_Profile_Api_RouteInitial";
import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
import {
choiceLabel,
criteriaLine,
DEFAULT_WEIGHTS,
metricsLine,
violationsLine,
violationText,
normalizeRouteInitial,
readInitialChoice,
rebalanceWeights,
relaxedLine,
sameBase,
serverBase,
WEIGHT_KEYS,
writeInitialChoice,
type InitialBase,
type InitialChoice,
type WeightKey,
} from "./B05_Profile_UI_RouteEdit_Initial_Model";
const WEIGHT_NAME: Record<WeightKey, string> = {
expected: "예상",
earthwork: "토공",
grade: "기울기",
};
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 };
/** [취소] — 이 창에서 바꾼 초기값을 창을 열 때 것으로 되돌린다. */
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 box = document.createElement("div");
box.className = "b05-routeedit__initial";
box.innerHTML = `
<div class="b05-routeedit__initial-row">
<button type="button" class="ui-btn ui-btn--glass" data-logic="earthwork" disabled
title="절토 · 성토가 가장 적은 노선을 서버가 셈해 초기값으로 싣습니다">토공 최소</button>
<button type="button" class="ui-btn ui-btn--glass" data-logic="gentle" disabled
title="종단기울기가 가장 순한 노선을 서버가 셈해 초기값으로 싣습니다">기울기 순한 노선</button>
<button type="button" class="ui-btn ui-btn--glass" data-act="weights-open" disabled
aria-expanded="false" title="예상 · 토공 · 기울기 비중(합 100)으로 셈합니다">비중 계산</button>
</div>
<div class="b05-routeedit__weights" hidden>
${WEIGHT_KEYS.map(
(key) => `<label><span>${WEIGHT_NAME[key]}</span>
<input type="range" min="0" max="100" step="1" data-weight="${key}" />
<output data-weight-out="${key}"></output></label>`,
).join("")}
<div class="b05-routeedit__weights-foot">
<span data-weight-sum></span>
<button type="button" class="ui-btn ui-btn--filled" data-logic="weighted">계산</button>
</div>
</div>
<div class="b05-routeedit__initial-now"></div>
<div class="b05-routeedit__initial-line" data-line="metrics"></div>
<div class="b05-routeedit__initial-line" data-line="violations" hidden></div>
<div class="b05-routeedit__initial-line is-warn" data-line="relaxed" hidden></div>
<div class="b05-routeedit__initial-line" data-line="criteria"></div>`;
overlay.querySelector(".b05-routeedit__canvas-wrap")!.append(box);
const nowLine = box.querySelector<HTMLElement>(".b05-routeedit__initial-now")!;
const metricsEl = box.querySelector<HTMLElement>('[data-line="metrics"]')!;
const criteriaEl = box.querySelector<HTMLElement>('[data-line="criteria"]')!;
const violationsEl = box.querySelector<HTMLElement>('[data-line="violations"]')!;
const relaxedEl = box.querySelector<HTMLElement>('[data-line="relaxed"]')!;
const weightsBox = box.querySelector<HTMLElement>(".b05-routeedit__weights")!;
const openWeights = box.querySelector<HTMLButtonElement>('[data-act="weights-open"]')!;
const resetButton = overlay.querySelector<HTMLButtonElement>('[data-act="reset"]')!;
const buttons = [...box.querySelectorAll<HTMLButtonElement>("button"), resetButton];
/** 창을 열 때 캐시에 있던 초기값 — [취소]가 돌아갈 자리. */
let openingChoice = readInitialChoice(projectId);
let choice: InitialChoice | null = openingChoice;
/** 노드 없이 이름만 아는 현재 초기값 — 서버가 적어 둔 것(없으면 예상노선 추종). */
let base: InitialBase = { logic: "follow", weights: null };
let weights: RouteInitialWeights = { ...(choice?.weights ?? DEFAULT_WEIGHTS) };
let opening: RouteEditSnapshot | null = null;
let edited = false;
let busy = false;
function render(): void {
const now = choice ?? base;
const logic = now.logic;
nowLine.textContent = `현재 초기값: ${choiceLabel(logic, now.weights)}`;
metricsEl.textContent =
choice && !choice.metrics
? "비교값 — 서버 답에 없습니다."
: metricsLine(choice?.metrics ?? null);
criteriaEl.textContent = criteriaLine(choice?.criteria ?? null, params.planMinRadiusM());
// 제약 못 지킨 구간 · 상한 풂 — 서버가 준 목록 그대로(계획서 20). 없는 칸이면 숨긴다.
violationsEl.textContent = violationsLine(choice?.metrics ?? null);
violationsEl.title = (choice?.metrics?.violations ?? [])
.map((item) => `${violationText(item)} — ${item.reason}`)
.join("\n");
violationsEl.hidden = !violationsEl.textContent;
relaxedEl.textContent = relaxedLine(choice);
relaxedEl.hidden = !relaxedEl.textContent;
box.dataset.logic = logic;
for (const key of WEIGHT_KEYS) {
box.querySelector<HTMLInputElement>(`[data-weight="${key}"]`)!.value = String(weights[key]);
box.querySelector<HTMLOutputElement>(`[data-weight-out="${key}"]`)!.value = `${weights[key]}`;
}
box.querySelector<HTMLElement>("[data-weight-sum]")!.textContent =
`합 ${weights.expected + weights.earthwork + weights.grade}`;
buttons.forEach((button) => {
button.disabled = busy || !opening;
});
resetButton.title = `마지막에 적용한 초기값(${nowLine.textContent.replace("현재 초기값: ", "")})으로 되돌립니다 — 다시 계산은 [확인]에서`;
}
/** 서버에 로직 하나를 묻고, 받은 노선을 싣고, 새 초기값으로 적는다. */
async function run(
logic: RouteInitialLogic,
allowFallback = false,
useWeights: RouteInitialWeights | null = null,
): Promise<void> {
if (busy || !opening) return;
busy = true;
const sent = logic === "weighted" ? { ...(useWeights ?? weights) } : null;
render();
nowLine.textContent = `${choiceLabel(logic, sent)} 셈하는 중…`;
try {
const response = await fetchRouteInitial(projectId, {
mode: logic,
...(sent ? { weights: sent } : {}),
});
if (!box.isConnected) return; // 그 사이 창이 닫혔다.
choice = normalizeRouteInitial(response, logic, sent);
writeInitialChoice(projectId, choice);
params.apply(
copy(choice.snapshot),
`초기값을 「${choiceLabel(logic, sent)}」로 바꿨습니다 — 다시 계산은 [확인]에서.`,
);
} catch (error) {
const message = error instanceof Error ? error.message : "초기노선을 셈하지 못했습니다.";
// 서버 길이 오기 전이라도 고친 적 없는 노선이면 창을 연 노선이 곧 예상노선 추종이다.
if (allowFallback && message === ROUTE_INITIAL_MISSING && !edited && logic === "follow") {
params.apply(
copy(opening),
"초기값(예상노선 추종)으로 되돌렸습니다 — 다시 계산은 [확인]에서.",
);
} else {
showToast(message, "error");
}
} finally {
busy = false;
if (box.isConnected) render();
}
}
box.querySelectorAll<HTMLButtonElement>("[data-logic]").forEach((button) => {
button.addEventListener("click", () => void run(button.dataset.logic as RouteInitialLogic));
});
openWeights.addEventListener("click", () => {
weightsBox.hidden = !weightsBox.hidden;
openWeights.setAttribute("aria-expanded", String(!weightsBox.hidden));
});
box.querySelectorAll<HTMLInputElement>("[data-weight]").forEach((input) => {
input.addEventListener("input", () => {
weights = rebalanceWeights(weights, input.dataset.weight as WeightKey, Number(input.value));
render();
});
});
// [초기화] — 마지막에 적용한 초기값으로. 노드가 없으면(서버가 이름만 앎) 그 갈래를 서버에 묻는다.
resetButton.addEventListener("click", () => {
if (!choice) {
void run(base.logic, true, base.weights);
return;
}
params.apply(
copy(choice.snapshot),
`초기값(${choiceLabel(choice.logic, choice.weights)})으로 되돌렸습니다 — 다시 계산은 [확인]에서.`,
);
});
render();
return {
ready(snapshot, wasEdited, server) {
opening = copy(snapshot);
edited = wasEdited;
// 「현재 초기값」은 서버가 [확인] 때 적어 둔 것이 정본이다 — 캐시가 다르면(확인 없이 닫음)
// 캐시를 버린다. 캐시는 같은 초기값의 노드를 들고 있을 때만 [초기화]가 쓴다.
if (server) {
base = serverBase(server);
if (choice && !sameBase(choice, base)) choice = null;
if (base.weights) weights = { ...base.weights };
openingChoice = choice;
writeInitialChoice(projectId, choice);
}
render();
},
current: () => {
const now = choice ?? base;
return { mode: now.logic, weights: now.weights };
},
cancel: () => writeInitialChoice(projectId, openingChoice),
keep: () => writeInitialChoice(projectId, choice),
};
}