- 공통 제약 기준값 확정(9-29 사용자 결정) — 후보 표 임시값 그대로 기본값, 화면에서 프로젝트마다 바꿀 수 있음 - 로직 단추 옆 제약 네 칸(비우면 placeholder 로 기본값) · 채운 칸만 POST /route/initial 몸통 criteria 로 실어 보냄 - [확인]의 initial.criteria 에도 실어 성공하면 initial_choice.json 에 겹친 칸만 남김(빈 칸은 기본값이라 안 담김) - 다시 열 때 current_initial.criteria 로 그 칸만 복원 · 캐시가 서버 갈래·비중·제약과 다르면 버림(sameBase) - 서버: generate_initial_route/_SearchGrid 가 stream_offset_m·grade_smooth_m 겹침을 받아 탐색·비교값에 반영 - 응답 criteria.pending 은 이제 늘 빈 목록 — 화면의 「후보 · 사용자 확정 대기」 글 삭제 시험: test_route_initial(+2) · test_b05_routeedit_initial(criteria 겹침·확정 표시 삭제) · test_b05_routeedit_initial_roundtrip(+1, 사본에서 계류 이격 30m 겹침 왕복) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015sCeZz8z8YnbovsKgn7HK7
337 lines
16 KiB
TypeScript
337 lines
16 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_UI_RouteEdit_Initial.ts
|
|
* 계획노선 편집 창의 **초기노선 갈래**(계획서 20) — 로직 단추 셋 · 공통 제약 겹침 네 칸 ·
|
|
* 비중 슬라이더 · [초기화] · 「현재 초기값」 · 결과 비교값 · 공통 제약 기준값.
|
|
*
|
|
* 로직 단추를 누르면 서버가 노선을 새로 셈하고(`/route/initial`), 받은 노드를 **편집값에
|
|
* 싣는다**(되돌리기 한 걸음). 그 결과가 새 초기값이라 캐시(`routeedit-initial`)에 적고,
|
|
* [초기화]는 서버를 다시 부르지 않고 그 초기값으로 돌아간다. 종단 · 배수유역 · 횡단 다시
|
|
* 계산은 여기서 안 돈다 — 노선이 바뀐 뒤 [확인]에서만(`_Apply`).
|
|
* 비교값 · 기준값은 서버가 준 것을 보이기만 한다(계산 자리 = 서버 단독).
|
|
*
|
|
* 공통 제약 네 칸(종단기울기 % · 최소 반지름 m · 계류 이격 m · 지반 고름 m, 계획서 20 확정)
|
|
* — 비우면 서버 기본값을 쓴다. 채운 칸만 요청에 실어 보내고(비운 칸은 서버가 알아서), 그
|
|
* 「채운 칸」만 캐시·`initial_choice.json`에 남아 다시 열 때 되살아난다. 빈 칸의 placeholder는
|
|
* 그 칸을 처음 비운 채 요청했을 때 서버가 답으로 준 값(진짜 기본값)을 기억해 둔 것이다.
|
|
* ========================================================================== */
|
|
|
|
import { showToast } from "@ui/ui_template_elements";
|
|
import {
|
|
fetchRouteInitial,
|
|
ROUTE_INITIAL_MISSING,
|
|
type RouteInitialCriteria,
|
|
type RouteInitialLogic,
|
|
type RouteInitialWeights,
|
|
} from "./B05_Profile_Api_RouteInitial";
|
|
import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
|
|
import {
|
|
choiceLabel,
|
|
criteriaLine,
|
|
CRITERIA_KEYS,
|
|
DEFAULT_WEIGHTS,
|
|
metricsLine,
|
|
violationsLine,
|
|
violationText,
|
|
normalizeRouteInitial,
|
|
readInitialChoice,
|
|
rebalanceWeights,
|
|
relaxedLine,
|
|
sameBase,
|
|
serverBase,
|
|
WEIGHT_KEYS,
|
|
writeInitialChoice,
|
|
type CriteriaKey,
|
|
type InitialBase,
|
|
type InitialChoice,
|
|
type InitialCriteria,
|
|
type WeightKey,
|
|
} from "./B05_Profile_UI_RouteEdit_Initial_Model";
|
|
|
|
const WEIGHT_NAME: Record<WeightKey, string> = {
|
|
expected: "예상",
|
|
earthwork: "토공",
|
|
grade: "기울기",
|
|
};
|
|
|
|
/** 공통 제약 칸 — 표시 이름 · 단위 · 서버가 준 값(`InitialCriteria`)에서 그 칸을 꺼내는 법. */
|
|
const CRITERIA_FIELD: Record<
|
|
CriteriaKey,
|
|
{ label: string; unit: string; title: string; of: (c: InitialCriteria) => number | null }
|
|
> = {
|
|
max_grade_pct: {
|
|
label: "기울기",
|
|
unit: "%",
|
|
title: "종단기울기 상한(%) — 비우면 프로젝트 기본값(설계속도 · 지형 표값)",
|
|
of: (c) => c.maxGradePct,
|
|
},
|
|
min_radius_m: {
|
|
label: "최소R",
|
|
unit: "m",
|
|
title: "최소 곡선반지름(m) — 비우면 프로젝트 기본값",
|
|
of: (c) => c.minRadiusM,
|
|
},
|
|
stream_offset_m: {
|
|
label: "계류",
|
|
unit: "m",
|
|
title: "계류 이격(m) — 비우면 기본값 100m",
|
|
of: (c) => c.streamOffsetM,
|
|
},
|
|
grade_smooth_m: {
|
|
label: "지반",
|
|
unit: "m",
|
|
title: "기울기 잴 지반 고름 폭(m) — 비우면 기본값 5m",
|
|
of: (c) => c.gradeSmoothM,
|
|
},
|
|
};
|
|
|
|
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;
|
|
};
|
|
/** [취소] — 이 창에서 바꾼 초기값을 창을 열 때 것으로 되돌린다. */
|
|
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__criteria-row">
|
|
${CRITERIA_KEYS.map(
|
|
(key) => `<label title="${CRITERIA_FIELD[key].title}">${CRITERIA_FIELD[key].label}
|
|
<input type="number" inputmode="decimal" step="0.1" min="0" disabled
|
|
data-criteria="${key}" placeholder="기본값" />${CRITERIA_FIELD[key].unit}</label>`,
|
|
).join("")}
|
|
</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];
|
|
const criteriaInputs = box.querySelectorAll<HTMLInputElement>("[data-criteria]");
|
|
|
|
/** 창을 열 때 캐시에 있던 초기값 — [취소]가 돌아갈 자리. */
|
|
let openingChoice = readInitialChoice(projectId);
|
|
let choice: InitialChoice | null = openingChoice;
|
|
/** 노드 없이 이름만 아는 현재 초기값 — 서버가 적어 둔 것(없으면 예상노선 추종). */
|
|
let base: InitialBase = { logic: "follow", weights: null, criteria: null };
|
|
let weights: RouteInitialWeights = { ...(choice?.weights ?? DEFAULT_WEIGHTS) };
|
|
/** 칸을 비운 채 처음 물었을 때 서버가 답한 값 — placeholder 로 보인다(진짜 기본값). */
|
|
const defaults: Partial<Record<CriteriaKey, number>> = {};
|
|
let opening: RouteEditSnapshot | null = null;
|
|
let edited = false;
|
|
let busy = false;
|
|
|
|
/** 화면 칸을 값으로 채운다(비운 칸은 빈 문자열 — placeholder 가 기본값을 보인다). */
|
|
function setCriteriaInputs(overrides: RouteInitialCriteria | null): void {
|
|
criteriaInputs.forEach((input) => {
|
|
const key = input.dataset.criteria as CriteriaKey;
|
|
const value = overrides?.[key];
|
|
input.value = value === undefined ? "" : String(value);
|
|
});
|
|
}
|
|
|
|
/** 화면 칸에 지금 사용자가 넣은 값만(빈 칸은 안 담김) — 다음 요청에 실어 보낼 겹침. */
|
|
function currentCriteria(): RouteInitialCriteria {
|
|
const out: RouteInitialCriteria = {};
|
|
criteriaInputs.forEach((input) => {
|
|
const raw = input.value.trim();
|
|
if (raw === "") return;
|
|
const value = Number(raw);
|
|
if (Number.isFinite(value)) out[input.dataset.criteria as CriteriaKey] = value;
|
|
});
|
|
return out;
|
|
}
|
|
|
|
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}`;
|
|
// 공통 제약 칸 — placeholder 는 그 칸을 처음 비운 채 물었을 때 서버가 준 진짜 기본값.
|
|
criteriaInputs.forEach((input) => {
|
|
const key = input.dataset.criteria as CriteriaKey;
|
|
input.placeholder = defaults[key] !== undefined ? String(defaults[key]) : "기본값";
|
|
input.disabled = busy || !opening;
|
|
});
|
|
buttons.forEach((button) => {
|
|
button.disabled = busy || !opening;
|
|
});
|
|
resetButton.title = `마지막에 적용한 초기값(${nowLine.textContent.replace("현재 초기값: ", "")})으로 되돌립니다 — 다시 계산은 [확인]에서`;
|
|
}
|
|
|
|
/** 서버에 로직 하나를 묻고, 받은 노선을 싣고, 새 초기값으로 적는다.
|
|
* `useCriteria` 를 안 주면 화면 칸에 지금 든 값을 그대로 겹침으로 보낸다. */
|
|
async function run(
|
|
logic: RouteInitialLogic,
|
|
allowFallback = false,
|
|
useWeights: RouteInitialWeights | null = null,
|
|
useCriteria?: RouteInitialCriteria | null,
|
|
): Promise<void> {
|
|
if (busy || !opening) return;
|
|
busy = true;
|
|
const sent = logic === "weighted" ? { ...(useWeights ?? weights) } : null;
|
|
const criteria = useCriteria !== undefined ? (useCriteria ?? {}) : currentCriteria();
|
|
render();
|
|
nowLine.textContent = `${choiceLabel(logic, sent)} 셈하는 중…`;
|
|
try {
|
|
const sentCriteria = Object.keys(criteria).length ? criteria : null;
|
|
const response = await fetchRouteInitial(projectId, {
|
|
mode: logic,
|
|
...(sent ? { weights: sent } : {}),
|
|
...(sentCriteria ? { criteria: sentCriteria } : {}),
|
|
});
|
|
if (!box.isConnected) return; // 그 사이 창이 닫혔다.
|
|
choice = normalizeRouteInitial(response, logic, sent, sentCriteria);
|
|
// 비운 칸(안 겹친 칸)을 물었을 때 서버가 준 값 — 그 칸의 진짜 기본값으로 기억해 둔다.
|
|
for (const key of CRITERIA_KEYS) {
|
|
if (criteria[key] !== undefined) continue;
|
|
const resolved = choice.criteria ? CRITERIA_FIELD[key].of(choice.criteria) : null;
|
|
if (resolved !== null) defaults[key] = resolved;
|
|
}
|
|
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) {
|
|
setCriteriaInputs(base.criteria); // 그 사이 손댄 칸이 있으면 서버가 기억한 값으로 되돌린다.
|
|
void run(base.logic, true, base.weights, base.criteria);
|
|
return;
|
|
}
|
|
params.apply(
|
|
copy(choice.snapshot),
|
|
`초기값(${choiceLabel(choice.logic, choice.weights)})으로 되돌렸습니다 — 다시 계산은 [확인]에서.`,
|
|
);
|
|
});
|
|
setCriteriaInputs(choice?.criteriaOverrides ?? null);
|
|
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 };
|
|
// 화면 칸도 그때 넣었던 값(캐시가 살아 있으면 캐시, 아니면 서버가 기억한 값)으로.
|
|
setCriteriaInputs(choice?.criteriaOverrides ?? base.criteria);
|
|
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 };
|
|
},
|
|
cancel: () => writeInitialChoice(projectId, openingChoice),
|
|
keep: () => writeInitialChoice(projectId, choice),
|
|
};
|
|
}
|