- 알약 = 예상노선 추적(종단 없음) · 영선 · 토공 최소 · 기울기 최소 · 비중 계산(넷은 종단 늘 반영) · 안전 우선 · 계류 횡단 최소 · 공사비 어림 최소 화면에서 뺌 - 「종단 반영」 켬/끔 단추 없앰(추천 창 · 종단 띠) — 띠 · [확인]은 지금 추천 갈래로 종단 여부 - ✕ 없애고 [적용] · [닫기]를 제목줄 오른쪽 위로 · 옵션 칸은 지도 왼쪽 위 작은 겹창 · 창 폭 넓힘 - 적용한 추천 갈래가 있으면 창을 다시 열 때 그 갈래가 골라진 채 - 도움말: 종단 반영 = 측점마다 횡단을 보고 정한 종단(적분값 아님) - 균형 종단 단면적에 곡선부 확폭(B05 측점 생성과 같은 셈) — 빼서 확정 뒤 성토가 1.7배 벌어졌음(노선 267 같은 계획고: 성 10,102 → 17,819 · 본 설계 16,140) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019EMfGERKUg5nMMNQsyVZ2j
533 lines
24 KiB
TypeScript
533 lines
24 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_UI_RouteEdit_Recommend.ts
|
|
* 계획노선 편집 창의 [노선 추천] 모달(계획서 23-2 · 77) — 알약 다섯 한 줄(예상노선 추적 · 영선 ·
|
|
* 토공 최소 · 기울기 최소 · 비중 계산) · 지도 안 옵션 겹창(제약 · 목표 기울기 · 비중) · 비교값 ·
|
|
* 못 지킨 구간 · 오른쪽 위 [적용] · [닫기].
|
|
* 종단은 갈래가 정한다 — 예상노선 추적만 종단 없음, 나머지는 늘 균형 종단(`withProfileOf`).
|
|
*
|
|
* 알약을 누르면 서버가 그 갈래를 셈해 주고(`request`) 지도에 미리 보기로 그린다. 편집 창의 노선은
|
|
* 건드리지 않는다 — [적용]을 눌러야 `onApply` 로 편집값에 실리고(되돌리기 한 걸음), [닫기]는
|
|
* 미리 보기를 버린다. 다시 계산(배수유역부터)은 여기서 안 돈다 — 편집 창 [확인]에서만.
|
|
* 비교값 · 기준값은 서버가 준 것을 보이기만 한다(계산 자리 = 서버 단독).
|
|
* 서버 부르기 · 캐시 · 「현재 추천」은 `_Initial` 몫이고 여기는 화면만 만든다.
|
|
* 균형 종단(계획서 63-4) — 후보마다 서버가 세운 종단의 한 줄 · 그림 · 셈한 후보 표는 이 모달
|
|
* 안에서만 보인다(`_Recommend_Profile`) · 닫으면 버린다.
|
|
* ========================================================================== */
|
|
|
|
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,
|
|
metricsLines,
|
|
TARGET_GRADE_KEY,
|
|
canLockWeight,
|
|
derivedWeightKey,
|
|
inputWeight,
|
|
moveWeight,
|
|
relaxedLine,
|
|
SIMILAR_HINT,
|
|
similarLine,
|
|
violationsLine,
|
|
violationText,
|
|
WEIGHT_KEYS,
|
|
withProfileOf,
|
|
type CriteriaKey,
|
|
type InitialChoice,
|
|
type InitialCriteria,
|
|
type MapLayer,
|
|
type WeightLocks,
|
|
type WeightKey,
|
|
} from "./B05_Profile_UI_RouteEdit_Initial_Model";
|
|
import { createRecommendMap, RECOMMEND_MAP_HTML } from "./B05_Profile_UI_RouteEdit_Recommend_Map";
|
|
import {
|
|
bindRecommendHelp,
|
|
recommendHelpHtml,
|
|
syncRecommendHelp,
|
|
} from "./B05_Profile_UI_RouteEdit_Recommend_Help";
|
|
import {
|
|
balanceLine,
|
|
candidatesTable,
|
|
profileSvg,
|
|
type BalanceProfile,
|
|
} from "./B05_Profile_UI_RouteEdit_Recommend_Profile";
|
|
import "./B05_Profile_UI_Style_RouteRecommend.css";
|
|
|
|
type Vertex = [number, number];
|
|
|
|
const PILLS = ["follow", "gradeline", "earthwork", "gentle", "weighted"] as const;
|
|
|
|
const PILL_TITLE: Record<(typeof PILLS)[number], string> = {
|
|
follow: "전처리가 만든 기본 노선 — 예상노선을 따라감 · 종단 없음(계획고 = 지반고)",
|
|
gradeline: "정한 종단기울기로 등고선을 따라가는 등경사선(영선) + 종단",
|
|
earthwork: "절토 · 성토가 가장 적은 노선 + 종단",
|
|
gentle: "종단기울기가 가장 낮은 노선 + 종단",
|
|
weighted: "예상 · 토공 · 기울기 비중(합 100)으로 셈한 노선 + 종단",
|
|
};
|
|
|
|
const WEIGHT_NAME: Record<WeightKey, string> = {
|
|
expected: "예상",
|
|
earthwork: "토공",
|
|
grade: "기울기",
|
|
};
|
|
|
|
/** 고정 단추 그림 — 자물쇠(글자 색을 따라 밝음 · 어두움에서 보인다). */
|
|
const LOCK_ICON = `<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" fill="none"
|
|
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
<rect x="3.5" y="7" width="9" height="6.5" rx="1.2" /><path d="M5.5 7V5a2.5 2.5 0 0 1 5 0v2" /></svg>`;
|
|
|
|
/** 공통 제약 칸 — 표시 이름 · 단위 · 서버가 준 값(`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;
|
|
/** 비중 · 고정 상태 — 편집 창이 사는 동안 모달을 닫았다 열어도 남는다. 여기서 고쳐 간다. */
|
|
weightMemory: { weights: RouteInitialWeights | null; locks: WeightLocks };
|
|
/** 처음 고른 갈래 — 적용한 추천이 있으면 그 갈래(PLAN 77-8). */
|
|
logic: RouteInitialLogic;
|
|
planMinRadiusM: number;
|
|
/** 지도 바탕 — 예상노선(원본 점 묶음) · 창을 연 노선(노드). */
|
|
expected: Vertex[];
|
|
current: Vertex[];
|
|
/** 서버에 갈래 하나를 묻고 편집값 한 벌로 펴 돌려준다(던지면 토스트). */
|
|
request: (
|
|
logic: RouteInitialLogic,
|
|
weights: RouteInitialWeights | null,
|
|
criteria: RouteInitialCriteria | null,
|
|
withProfile: boolean,
|
|
) => Promise<InitialChoice>;
|
|
/** [적용] — 미리 본 노선을 편집 창에 놓는다. */
|
|
onApply: (choice: InitialChoice) => void;
|
|
}
|
|
|
|
export function openRecommendModal(params: RecommendParams): void {
|
|
// 알약에 없는 옛 갈래(안전 우선 등)로 적용했던 노선이면 예상노선 추적에서 시작한다.
|
|
let logic: RouteInitialLogic = (PILLS as readonly string[]).includes(params.logic)
|
|
? params.logic
|
|
: "follow";
|
|
const { locks } = params.weightMemory;
|
|
let weights: RouteInitialWeights = { ...(params.weightMemory.weights ?? params.weights) };
|
|
/** 값 칸에 넣은 숫자가 고정 규칙 아래 합 100 을 못 맞추는 비중 — 빨간 테두리 · 서버에 안 보냄. */
|
|
const invalid = new Set<WeightKey>();
|
|
let preview: InitialChoice | null = null;
|
|
let busy = false;
|
|
/** 마지막 미리 보기 뒤 슬라이더 · 제약 칸을 만졌다 — 미리 본 노선이 칸 값과 다르다. */
|
|
let dirty = false;
|
|
let closed = false;
|
|
let seq = 0;
|
|
/** 이 모달에서 셈한 후보의 균형 종단(계획서 63-4) — 모달이 닫히면 버린다(캐시 · 저장 없음). */
|
|
const seen = new Map<string, BalanceProfile>();
|
|
let ticker: number | undefined;
|
|
|
|
let bodyEl: HTMLElement | null = null;
|
|
let map: ReturnType<typeof createRecommendMap> | null = null;
|
|
openModal({
|
|
title: "노선 추천",
|
|
closeLabel: "닫기", // 제목줄 단추는 mount 에서 [적용] · [닫기] 묶음으로 갈아 끼운다(PLAN 77-3)
|
|
dialogClass: "b05-routerec",
|
|
// 곡선 라벨 패널(z-index 1001)이 떠 있어도 이 모달과 뒤 막이 그 위에 오게 한다.
|
|
backdropClass: "b05-routerec-backdrop",
|
|
// [닫기] · Esc · 바깥 누르기 모두 미리 보기를 버린다.
|
|
beforeClose: () => {
|
|
closed = true;
|
|
window.clearInterval(ticker);
|
|
map?.dispose();
|
|
return true;
|
|
},
|
|
mount: (body, close) => {
|
|
bodyEl = body;
|
|
body.classList.add("b05-routerec__body");
|
|
body.innerHTML = `
|
|
<div class="b05-routerec__actions">
|
|
<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>
|
|
<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 class="b05-routerec__side" role="group" aria-label="추천 옵션">
|
|
<div class="b05-routerec__weights" hidden>
|
|
<div class="b05-routerec__bars">
|
|
${WEIGHT_KEYS.map(
|
|
(key) => `<div class="b05-routerec__bar">
|
|
<input type="number" class="b05-routerec__num" min="0" max="100" step="1"
|
|
inputmode="numeric" data-weight-num="${key}" aria-label="${WEIGHT_NAME[key]} 비중 값" />
|
|
<input type="range" min="0" max="100" step="1" data-weight="${key}"
|
|
aria-label="${WEIGHT_NAME[key]} 비중" />
|
|
<button type="button" class="b05-routerec__lock" data-weight-lock="${key}"
|
|
aria-pressed="false" aria-label="${WEIGHT_NAME[key]} 비중 고정">${LOCK_ICON}</button>
|
|
<span>${WEIGHT_NAME[key]}</span></div>`,
|
|
).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>
|
|
<label class="b05-routerec__target" data-target-row hidden
|
|
title="영선 노선이 따라갈 종단기울기(%) — 비우면 위 종단기울기 상한">목표 기울기
|
|
<input type="number" inputmode="decimal" step="0.1" min="0" max="100"
|
|
data-criteria="${TARGET_GRADE_KEY}" placeholder="상한" />%</label>
|
|
<button type="button" class="ui-btn ui-btn--glass" data-act="rerun" hidden>다시 셈</button>
|
|
</div>
|
|
</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">휠 = 확대 · 축소 끌기 = 이동 ↺ ↻ = 회전</div>
|
|
</div>
|
|
</div>
|
|
<div class="b05-routerec__lines">
|
|
<div data-line="metrics"></div>
|
|
<div data-line="metrics-work"></div>
|
|
<div data-line="balance" hidden></div>
|
|
<div class="b05-routerec__balance">
|
|
<div class="b05-routerec__profile" data-line="profile" hidden></div>
|
|
<div class="b05-routerec__cands" data-line="candidates" hidden></div>
|
|
</div>
|
|
<div data-line="violations" hidden></div>
|
|
<div class="is-warn" data-line="relaxed" hidden></div>
|
|
<div data-line="similar" hidden></div>
|
|
<div class="is-dim" data-line="similar-hint" hidden></div>
|
|
<div data-line="criteria"></div>
|
|
</div>
|
|
<div class="b05-routerec__foot">
|
|
<span class="b05-routerec__state" data-state></span>
|
|
</div>
|
|
${recommendHelpHtml()}`;
|
|
|
|
const q = <T extends HTMLElement>(sel: string): T => body.querySelector<T>(sel)!;
|
|
// [적용] · [닫기]를 제목줄 오른쪽 위로 — 공용 겉틀의 닫기 단추와 갈아 끼운다(PLAN 77-3).
|
|
const actions = q<HTMLElement>(".b05-routerec__actions");
|
|
const applyButton = q<HTMLButtonElement>('[data-act="apply"]');
|
|
const closeButton = q<HTMLButtonElement>('[data-act="close"]');
|
|
body.parentElement?.querySelector(".ui-modal__top > button")?.replaceWith(actions);
|
|
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 stateEl = q<HTMLElement>("[data-state]");
|
|
const line = (name: string): HTMLElement => q<HTMLElement>(`[data-line="${name}"]`);
|
|
const criteriaInputs = body.querySelectorAll<HTMLInputElement>("[data-criteria]");
|
|
bindRecommendHelp(body);
|
|
|
|
criteriaInputs.forEach((input) => {
|
|
const value =
|
|
params.criteria?.[input.dataset.criteria as CriteriaKey | typeof TARGET_GRADE_KEY];
|
|
input.value = value === undefined ? "" : String(value);
|
|
});
|
|
|
|
/** 칸에 지금 든 값만(빈 칸은 안 담김) — 서버에 실어 보낼 겹침. */
|
|
function currentCriteria(): RouteInitialCriteria {
|
|
const out: RouteInitialCriteria = {};
|
|
criteriaInputs.forEach((input) => {
|
|
const key = input.dataset.criteria as CriteriaKey | typeof TARGET_GRADE_KEY;
|
|
// 목표 기울기는 영선 노선을 물을 때만 보낸다.
|
|
if (key === TARGET_GRADE_KEY && logic !== "gradeline") return;
|
|
const raw = input.value.trim();
|
|
if (raw === "") return;
|
|
const value = Number(raw);
|
|
if (Number.isFinite(value)) out[key] = 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";
|
|
const derived = derivedWeightKey(locks);
|
|
for (const key of WEIGHT_KEYS) {
|
|
const slider = q<HTMLInputElement>(`[data-weight="${key}"]`);
|
|
const num = q<HTMLInputElement>(`[data-weight-num="${key}"]`);
|
|
const lock = q<HTMLButtonElement>(`[data-weight-lock="${key}"]`);
|
|
// 고정했거나 나머지 둘이 고정돼 저절로 정해진 비중은 잠김 — 움직이지 않는다.
|
|
const shut = locks[key] || derived === key;
|
|
slider.value = String(weights[key]);
|
|
slider.disabled = busy || shut;
|
|
// 넣다 만 틀린 글은 지우지 않는다 — 빨간 테두리로 남겨 고치게 한다.
|
|
if (!invalid.has(key)) num.value = String(weights[key]);
|
|
num.disabled = busy || shut;
|
|
num.classList.toggle("is-invalid", invalid.has(key));
|
|
num.setAttribute("aria-invalid", String(invalid.has(key)));
|
|
lock.setAttribute("aria-pressed", String(shut));
|
|
lock.classList.toggle("is-on", shut);
|
|
lock.classList.toggle("is-auto", derived === key);
|
|
// 셋 다 고정은 막는다 — 마지막 하나는 고정 단추가 꺼지고 잠김으로만 보인다.
|
|
lock.disabled = busy || !canLockWeight(locks, key) || derived === key;
|
|
lock.title =
|
|
derived === key
|
|
? "나머지 둘이 고정돼 저절로 정해집니다"
|
|
: locks[key]
|
|
? "고정 풀기"
|
|
: "이 값 고정 — 다른 비중을 움직여도 안 바뀝니다";
|
|
}
|
|
q<HTMLElement>("[data-weight-sum]").textContent =
|
|
`합 ${weights.expected + weights.earthwork + weights.grade}`;
|
|
criteriaInputs.forEach((input) => {
|
|
const key = input.dataset.criteria as CriteriaKey | typeof TARGET_GRADE_KEY;
|
|
// 목표 기울기를 비우면 종단기울기 상한을 쓴다 — 그 값이 자리표시.
|
|
const known =
|
|
key === TARGET_GRADE_KEY ? params.defaults.max_grade_pct : params.defaults[key];
|
|
input.placeholder =
|
|
known !== undefined ? String(known) : key === TARGET_GRADE_KEY ? "상한" : "기본값";
|
|
input.disabled = busy;
|
|
});
|
|
q<HTMLElement>("[data-target-row]").hidden = logic !== "gradeline";
|
|
rerun.hidden = !dirty;
|
|
rerun.disabled = busy || invalid.size > 0;
|
|
applyButton.disabled = busy || dirty || !preview || invalid.size > 0;
|
|
const shown = metricsLines(preview?.metrics ?? null);
|
|
line("metrics").textContent = preview
|
|
? preview.metrics
|
|
? shown.route
|
|
: "비교값 — 서버 답에 없습니다."
|
|
: "비교값 — 알약을 누르면 서버가 셈해 미리 보여 줍니다.";
|
|
line("metrics-work").textContent = preview ? shown.work : "";
|
|
line("metrics-work").hidden = !line("metrics-work").textContent;
|
|
const balance = preview?.balance ?? null;
|
|
line("balance").textContent = balanceLine(balance);
|
|
line("balance").hidden = !balance;
|
|
line("profile").innerHTML = profileSvg(balance);
|
|
line("profile").hidden = !balance;
|
|
line("candidates").innerHTML = candidatesTable([...seen]);
|
|
line("candidates").hidden = seen.size === 0;
|
|
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;
|
|
const similar = similarLine(preview);
|
|
line("similar").textContent = similar;
|
|
line("similar").hidden = !similar;
|
|
line("similar-hint").textContent = similar ? SIMILAR_HINT : "";
|
|
line("similar-hint").hidden = !similar;
|
|
line("criteria").textContent = preview
|
|
? criteriaLine(preview.criteria, params.planMinRadiusM, preview.logic === "gradeline")
|
|
: "";
|
|
line("criteria").hidden = !preview;
|
|
syncRecommendHelp(body, logic);
|
|
drawMap();
|
|
}
|
|
|
|
async function run(): Promise<void> {
|
|
if (logic === "weighted" && invalid.size > 0) {
|
|
showToast("비중 값이 합 100 을 못 맞춥니다 — 빨간 칸을 고쳐 주세요.", "error");
|
|
return;
|
|
}
|
|
const mine = ++seq;
|
|
const sentWeights = logic === "weighted" ? { ...weights } : null;
|
|
const criteria = currentCriteria();
|
|
const withProfile = withProfileOf(logic);
|
|
busy = true;
|
|
dirty = false;
|
|
preview = null;
|
|
// 후보마다 균형 종단을 셈해 처음 한 번은 수십 초 — 지난 초를 보인다(계획서 63-4).
|
|
const started = Date.now();
|
|
const label = choiceLabel(logic, sentWeights);
|
|
const tick = (): void => {
|
|
const seconds = Math.round((Date.now() - started) / 1000);
|
|
stateEl.textContent = `${label} 셈하는 중… ${seconds}초${
|
|
withProfile ? " (후보마다 측점 횡단으로 종단을 셈합니다)" : " (종단 없음 — 원지반만)"
|
|
}`;
|
|
};
|
|
window.clearInterval(ticker);
|
|
tick();
|
|
ticker = window.setInterval(tick, 1000);
|
|
render();
|
|
try {
|
|
const choice = await params.request(
|
|
logic,
|
|
sentWeights,
|
|
Object.keys(criteria).length ? criteria : null,
|
|
withProfile,
|
|
);
|
|
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;
|
|
if (choice.balance) seen.set(label, choice.balance);
|
|
else seen.delete(label); // 종단 없는 갈래(예상노선 추적)
|
|
stateEl.textContent = `미리 보기: ${choiceLabel(logic, sentWeights)} — [적용]을 눌러야 편집 창에 놓입니다.`;
|
|
} catch (error) {
|
|
if (closed || mine !== seq) return;
|
|
stateEl.textContent = "셈하지 못했습니다.";
|
|
showToast(
|
|
error instanceof Error ? error.message : "초기노선을 셈하지 못했습니다.",
|
|
"error",
|
|
);
|
|
} finally {
|
|
if (mine === seq) window.clearInterval(ticker);
|
|
if (!closed && mine === seq) {
|
|
busy = false;
|
|
render();
|
|
}
|
|
}
|
|
}
|
|
|
|
pills.forEach((pill) => {
|
|
pill.addEventListener("click", () => {
|
|
if (busy) return;
|
|
logic = pill.dataset.logic as RouteInitialLogic;
|
|
void run();
|
|
});
|
|
});
|
|
const remember = (): void => {
|
|
params.weightMemory.weights = { ...weights };
|
|
};
|
|
body.querySelectorAll<HTMLInputElement>("[data-weight]").forEach((input) => {
|
|
input.addEventListener("input", () => {
|
|
weights = moveWeight(
|
|
weights,
|
|
locks,
|
|
input.dataset.weight as WeightKey,
|
|
Number(input.value),
|
|
);
|
|
invalid.clear();
|
|
remember();
|
|
dirty = preview !== null;
|
|
render();
|
|
});
|
|
});
|
|
// 값 칸 — 0~100 정수 · 고정 규칙 아래 합 100 을 못 맞추면 받지 않고 빨간 테두리.
|
|
body.querySelectorAll<HTMLInputElement>("[data-weight-num]").forEach((input) => {
|
|
input.addEventListener("input", () => {
|
|
const key = input.dataset.weightNum as WeightKey;
|
|
const raw = input.value.trim();
|
|
const result = inputWeight(weights, locks, key, raw === "" ? Number.NaN : Number(raw));
|
|
if (result.ok) {
|
|
weights = result.weights;
|
|
invalid.clear();
|
|
remember();
|
|
dirty = preview !== null;
|
|
// 다른 칸의 글도 새 값으로 — 이 칸은 넣은 그대로 둔다.
|
|
body.querySelectorAll<HTMLInputElement>("[data-weight-num]").forEach((other) => {
|
|
if (other !== input)
|
|
other.value = String(weights[other.dataset.weightNum as WeightKey]);
|
|
});
|
|
} else {
|
|
invalid.add(key);
|
|
}
|
|
render();
|
|
});
|
|
// 칸을 떠날 때 맞는 값이면 글을 값으로 다듬는다(예: 070 → 70).
|
|
input.addEventListener("blur", () => {
|
|
if (!invalid.has(input.dataset.weightNum as WeightKey)) render();
|
|
});
|
|
});
|
|
// 고정 단추 — 지금 값을 붙든다. 셋 다 고정은 막혀 있다(단추가 꺼짐).
|
|
body.querySelectorAll<HTMLButtonElement>("[data-weight-lock]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const key = button.dataset.weightLock as WeightKey;
|
|
if (!canLockWeight(locks, key)) return;
|
|
locks[key] = !locks[key];
|
|
invalid.clear();
|
|
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();
|
|
});
|
|
closeButton.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();
|
|
});
|
|
}
|