Files
Aislo/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts
T

360 lines
15 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_RouteEdit_Recommend.ts
* 계획노선 편집 창의 [노선 추천] 모달(계획서 23-2) — 알약 넷(예상노선 추적 · 토공 최소 ·
* 기울기 순한 · 비중 계산) · 비중 슬라이더 셋 · 공통 제약 네 칸 · 미리 보기 지도 · 비교값 ·
* 못 지킨 구간 · [적용] · [닫기].
*
* 알약을 누르면 서버가 그 갈래를 셈해 주고(`request`) 지도에 미리 보기로 그린다. 편집 창의 노선은
* 건드리지 않는다 — [적용]을 눌러야 `onApply` 로 편집값에 실리고(되돌리기 한 걸음), [닫기]는
* 미리 보기를 버린다. 다시 계산(배수유역부터)은 여기서 안 돈다 — 편집 창 [확인]에서만.
* 비교값 · 기준값은 서버가 준 것을 보이기만 한다(계산 자리 = 서버 단독).
* 서버 부르기 · 캐시 · 「현재 추천」은 `_Initial` 몫이고 여기는 화면만 만든다.
* ========================================================================== */
import { openModal } from "@ui/ui_template_modal";
import { showToast } from "@ui/ui_template_elements";
import type {
RouteInitialCriteria,
RouteInitialLogic,
RouteInitialWeights,
} from "./B05_Profile_Api_RouteInitial";
import {
choiceLabel,
criteriaLine,
CRITERIA_KEYS,
LOGIC_LABEL,
metricsLine,
rebalanceWeights,
relaxedLine,
violationsLine,
violationText,
WEIGHT_KEYS,
type CriteriaKey,
type InitialChoice,
type InitialCriteria,
type MapLayer,
type WeightKey,
} from "./B05_Profile_UI_RouteEdit_Initial_Model";
import { createRecommendMap, RECOMMEND_MAP_HTML } from "./B05_Profile_UI_RouteEdit_Recommend_Map";
import "./B05_Profile_UI_Style_RouteRecommend.css";
type Vertex = [number, number];
const PILLS: RouteInitialLogic[] = ["follow", "earthwork", "gentle", "weighted"];
const PILL_TITLE: Record<RouteInitialLogic, string> = {
follow: "전처리가 만든 기본 노선 — 예상노선을 따라갑니다",
earthwork: "절토 · 성토가 가장 적은 노선",
gentle: "종단기울기가 가장 순한 노선",
weighted: "예상 · 토공 · 기울기 비중(합 100)으로 셈한 노선",
};
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: "최소 반지름",
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 RecommendParams {
/** 칸에 처음 채울 제약 겹침(직접 넣은 값만). */
criteria: RouteInitialCriteria | null;
/** 칸을 비운 채 처음 물었을 때 서버가 답한 값 — 빈 칸 placeholder. 여기서 채워 간다. */
defaults: Partial<Record<CriteriaKey, number>>;
weights: RouteInitialWeights;
planMinRadiusM: number;
/** 지도 바탕 — 예상노선(원본 점 묶음) · 창을 연 노선(노드). */
expected: Vertex[];
current: Vertex[];
/** 서버에 갈래 하나를 묻고 편집값 한 벌로 펴 돌려준다(던지면 토스트). */
request: (
logic: RouteInitialLogic,
weights: RouteInitialWeights | null,
criteria: RouteInitialCriteria | null,
) => Promise<InitialChoice>;
/** [적용] — 미리 본 노선을 편집 창에 놓는다. */
onApply: (choice: InitialChoice) => void;
}
export function openRecommendModal(params: RecommendParams): void {
let logic: RouteInitialLogic = "follow";
let weights: RouteInitialWeights = { ...params.weights };
let preview: InitialChoice | null = null;
let busy = false;
/** 마지막 미리 보기 뒤 슬라이더 · 제약 칸을 만졌다 — 미리 본 노선이 칸 값과 다르다. */
let dirty = false;
let closed = false;
let seq = 0;
let bodyEl: HTMLElement | null = null;
let map: ReturnType<typeof createRecommendMap> | null = null;
openModal({
title: "노선 추천",
closeLabel: "✕",
dialogClass: "b05-routerec",
// 곡선 라벨 패널(z-index 1001)이 떠 있어도 이 모달과 뒤 막이 그 위에 오게 한다.
backdropClass: "b05-routerec-backdrop",
// ✕ · Esc · 바깥 누르기 모두 [닫기]와 같다 — 미리 보기를 버린다.
beforeClose: () => {
closed = true;
map?.dispose();
return true;
},
mount: (body, close) => {
bodyEl = body;
body.classList.add("b05-routerec__body");
body.innerHTML = `
<div class="b05-routerec__pills" role="radiogroup" aria-label="추천 갈래">
${PILLS.map(
(key) => `<button type="button" class="b05-routerec__pill" role="radio"
data-logic="${key}" title="${PILL_TITLE[key]}">${LOGIC_LABEL[key]}</button>`,
).join("")}
</div>
<div class="b05-routerec__main">
<div class="b05-routerec__mapbox">
<div class="b05-routerec__mapwrap" role="img" aria-label="추천 노선 미리 보기">
${RECOMMEND_MAP_HTML}
</div>
<div class="b05-routerec__legend">
<span><i class="is-expected"></i>예상노선(원본)</span>
<span><i class="is-current"></i>창을 연 노선</span>
<span><i class="is-preview"></i>미리 보기</span>
</div>
<div class="b05-routerec__maphint">휠 = 확대 · 축소 &nbsp; 끌기 = 이동 &nbsp; ↺ ↻ = 회전</div>
</div>
<div class="b05-routerec__side">
<div class="b05-routerec__weights" hidden>
<div class="b05-routerec__bars">
${WEIGHT_KEYS.map(
(
key,
) => `<label class="b05-routerec__bar"><output data-weight-out="${key}"></output>
<input type="range" min="0" max="100" step="1" data-weight="${key}"
aria-label="${WEIGHT_NAME[key]} 비중" />
<span>${WEIGHT_NAME[key]}</span></label>`,
).join("")}
</div>
<span class="b05-routerec__sum" data-weight-sum></span>
</div>
<div class="b05-routerec__criteria">
${CRITERIA_KEYS.map(
(key) => `<label title="${CRITERIA_FIELD[key].title}">${CRITERIA_FIELD[key].label}
<input type="number" inputmode="decimal" step="0.1" min="0"
data-criteria="${key}" placeholder="기본값" />${CRITERIA_FIELD[key].unit}</label>`,
).join("")}
</div>
<button type="button" class="ui-btn ui-btn--glass" data-act="rerun" hidden>다시 셈</button>
</div>
</div>
<div class="b05-routerec__lines">
<div data-line="metrics"></div>
<div data-line="violations" hidden></div>
<div class="is-warn" data-line="relaxed" hidden></div>
<div data-line="criteria"></div>
</div>
<div class="b05-routerec__foot">
<span class="b05-routerec__state" data-state></span>
<button type="button" class="ui-btn ui-btn--filled" data-act="apply" disabled>적용</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="close">닫기</button>
</div>`;
const q = <T extends HTMLElement>(sel: string): T => body.querySelector<T>(sel)!;
map = createRecommendMap(q<HTMLElement>(".b05-routerec__mapwrap"));
const pills = [...body.querySelectorAll<HTMLButtonElement>("[data-logic]")];
const weightsBox = q<HTMLElement>(".b05-routerec__weights");
const rerun = q<HTMLButtonElement>('[data-act="rerun"]');
const applyButton = q<HTMLButtonElement>('[data-act="apply"]');
const stateEl = q<HTMLElement>("[data-state]");
const line = (name: string): HTMLElement => q<HTMLElement>(`[data-line="${name}"]`);
const criteriaInputs = body.querySelectorAll<HTMLInputElement>("[data-criteria]");
criteriaInputs.forEach((input) => {
const value = params.criteria?.[input.dataset.criteria as CriteriaKey];
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 drawMap(): void {
const layers: MapLayer[] = [
{ kind: "expected", points: params.expected },
{ kind: "current", points: params.current },
];
if (preview) layers.push({ kind: "preview", points: preview.snapshot.planned });
map?.setLayers(layers);
}
function render(): void {
pills.forEach((pill) => {
const on = pill.dataset.logic === logic;
pill.setAttribute("aria-checked", String(on));
pill.classList.toggle("is-on", on);
pill.disabled = busy;
});
weightsBox.hidden = logic !== "weighted";
for (const key of WEIGHT_KEYS) {
q<HTMLInputElement>(`[data-weight="${key}"]`).value = String(weights[key]);
q<HTMLOutputElement>(`[data-weight-out="${key}"]`).value = `${weights[key]}`;
}
q<HTMLElement>("[data-weight-sum]").textContent =
`합 ${weights.expected + weights.earthwork + weights.grade}`;
criteriaInputs.forEach((input) => {
const key = input.dataset.criteria as CriteriaKey;
input.placeholder =
params.defaults[key] !== undefined ? String(params.defaults[key]) : "기본값";
input.disabled = busy;
});
rerun.hidden = !dirty;
rerun.disabled = busy;
applyButton.disabled = busy || dirty || !preview;
line("metrics").textContent = preview
? preview.metrics
? metricsLine(preview.metrics)
: "비교값 — 서버 답에 없습니다."
: "비교값 — 알약을 누르면 서버가 셈해 미리 보여 줍니다.";
const violations = violationsLine(preview?.metrics ?? null);
line("violations").textContent = violations;
line("violations").title = (preview?.metrics?.violations ?? [])
.map((item) => `${violationText(item)} — ${item.reason}`)
.join("\n");
line("violations").hidden = !violations;
const relaxed = relaxedLine(preview);
line("relaxed").textContent = relaxed;
line("relaxed").hidden = !relaxed;
line("criteria").textContent = preview
? criteriaLine(preview.criteria, params.planMinRadiusM)
: "";
line("criteria").hidden = !preview;
drawMap();
}
async function run(): Promise<void> {
const mine = ++seq;
const sentWeights = logic === "weighted" ? { ...weights } : null;
const criteria = currentCriteria();
busy = true;
dirty = false;
preview = null;
stateEl.textContent = `${choiceLabel(logic, sentWeights)} 셈하는 중…`;
render();
try {
const choice = await params.request(
logic,
sentWeights,
Object.keys(criteria).length ? criteria : null,
);
if (closed || mine !== seq) return;
// 비운 칸을 물었을 때 서버가 준 값 — 그 칸의 진짜 기본값으로 기억해 둔다.
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) params.defaults[key] = resolved;
}
preview = choice;
stateEl.textContent = `미리 보기: ${choiceLabel(logic, sentWeights)} — [적용]을 눌러야 편집 창에 놓입니다.`;
} catch (error) {
if (closed || mine !== seq) return;
stateEl.textContent = "셈하지 못했습니다.";
showToast(
error instanceof Error ? error.message : "초기노선을 셈하지 못했습니다.",
"error",
);
} finally {
if (!closed && mine === seq) {
busy = false;
render();
}
}
}
pills.forEach((pill) => {
pill.addEventListener("click", () => {
if (busy) return;
logic = pill.dataset.logic as RouteInitialLogic;
void run();
});
});
body.querySelectorAll<HTMLInputElement>("[data-weight]").forEach((input) => {
input.addEventListener("input", () => {
weights = rebalanceWeights(
weights,
input.dataset.weight as WeightKey,
Number(input.value),
);
dirty = preview !== null;
render();
});
});
criteriaInputs.forEach((input) => {
input.addEventListener("input", () => {
dirty = preview !== null;
render();
});
});
rerun.addEventListener("click", () => void run());
applyButton.addEventListener("click", () => {
if (!preview) return;
params.onApply(preview);
closed = true;
close();
});
q<HTMLButtonElement>('[data-act="close"]').addEventListener("click", () => {
closed = true;
close();
});
render();
// 처음 고른 상태 = 예상노선 추적 — 열자마자 그 미리 보기를 받는다.
void run();
},
});
// 편집 창 단축키(Ctrl+Z 등)가 뒤에서 노선을 되돌리지 않게 막는다. 칸 안의 글자 되돌리기는 그대로.
(bodyEl as HTMLElement | null)
?.closest(".ui-modal__backdrop")
?.addEventListener("keydown", (event) => {
const key = event as KeyboardEvent;
if (key.ctrlKey || key.metaKey) key.stopPropagation();
});
}