auto: 2026-09-30 07:36 (ESD_LAPTOP)

This commit is contained in:
2026-09-30 07:36:32 +09:00
parent bf21676ef5
commit 559d445e4e
6 changed files with 304 additions and 13 deletions
@@ -27,6 +27,7 @@ import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
import {
choiceLabel,
DEFAULT_WEIGHTS,
NO_WEIGHT_LOCKS,
initialRouteSnapshot,
normalizeRouteInitial,
readInitialChoice,
@@ -85,6 +86,11 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
let opening: RouteEditSnapshot | null = null;
let edited = false;
let busy = false;
/** 비중 · 고정 — 모달을 닫았다 열어도 이 편집 창이 사는 동안 남는다. */
const weightMemory = {
weights: null as RouteInitialWeights | null,
locks: { ...NO_WEIGHT_LOCKS },
};
/** `/route/plan` 한 번 읽은 것 — 예상노선(지도 바탕) · 초기값 노선([초기화]). 창이 사는 동안 안 바뀐다. */
let planRead: Promise<RoutePlanResponse> | null = null;
@@ -130,6 +136,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
criteria: choice?.criteriaOverrides ?? base.criteria,
defaults,
weights: { ...(choice?.weights ?? base.weights ?? DEFAULT_WEIGHTS) },
weightMemory,
planMinRadiusM: params.planMinRadiusM(),
expected: (plan?.expected ?? []).map(([x, y]) => [x, y]),
current: opening!.planned.map(([x, y]) => [x, y]),
@@ -51,6 +51,68 @@ export function rebalanceWeights(
return { expected: out.expected, earthwork: out.earthwork, grade: out.grade };
}
/** 비중마다 고정 표시 — 고정한 값은 다른 비중을 움직여도 안 바뀐다. */
export type WeightLocks = Record<WeightKey, boolean>;
export const NO_WEIGHT_LOCKS: WeightLocks = { expected: false, earthwork: false, grade: false };
const lockedKeys = (locks: WeightLocks): WeightKey[] => WEIGHT_KEYS.filter((name) => locks[name]);
/** 나머지 둘이 고정돼 저절로 정해지는 비중 — 없으면 null. 이 비중은 잠김으로 보인다. */
export function derivedWeightKey(locks: WeightLocks): WeightKey | null {
const free = WEIGHT_KEYS.filter((name) => !locks[name]);
return free.length === 1 ? free[0] : null;
}
/** 이 비중의 고정 단추를 새로 누를 수 있나 — 셋 다 고정은 막는다(마지막 하나는 저절로 잠김).
* 이미 고정한 것을 푸는 것은 늘 된다. */
export function canLockWeight(locks: WeightLocks, key: WeightKey): boolean {
return locks[key] || lockedKeys(locks).length < 2;
}
/** 슬라이더로 한 비중을 옮긴다 — 고정한 것은 그대로, 안 고정한 것끼리 합 100 을 맞춘다.
* 고정하지 않은 것이 셋이면 나머지 둘이 지금 비율대로(`rebalanceWeights`), 둘이면 다른 하나가 남는 몫을
* 받고, 이 비중 혼자면(나머지 둘 고정) 값이 정해져 있어 안 움직인다. 값은 남는 몫 안으로 잘린다. */
export function moveWeight(
weights: RouteInitialWeights,
locks: WeightLocks,
key: WeightKey,
value: number,
): RouteInitialWeights {
if (locks[key]) return { ...weights };
const fixedSum = lockedKeys(locks).reduce((sum, name) => sum + weights[name], 0);
const room = 100 - fixedSum;
const free = WEIGHT_KEYS.filter((name) => !locks[name]);
if (free.length === WEIGHT_KEYS.length) return rebalanceWeights(weights, key, value);
const out = { ...weights };
if (free.length === 1) {
out[key] = room;
return out;
}
const other = free.find((name) => name !== key)!;
const next = Math.min(room, Math.max(0, Math.round(Number.isFinite(value) ? value : 0)));
out[key] = next;
out[other] = room - next;
return out;
}
/** 값 칸에 직접 넣은 숫자 — 0~100 정수이고 고정 규칙 아래 합 100 을 맞출 수 있을 때만 받는다.
* 못 맞추면 `ok: false`(칸에 빨간 테두리 · 서버에 안 보냄). 고정한 비중은 고칠 수 없다. */
export function inputWeight(
weights: RouteInitialWeights,
locks: WeightLocks,
key: WeightKey,
value: number,
): { ok: boolean; weights: RouteInitialWeights } {
const same = { ok: false, weights: { ...weights } };
if (locks[key] || !Number.isInteger(value) || value < 0 || value > 100) return same;
const room = 100 - lockedKeys(locks).reduce((sum, name) => sum + weights[name], 0);
const free = WEIGHT_KEYS.filter((name) => !locks[name]);
if (free.length === 1) return value === room ? { ok: true, weights: { ...weights } } : same;
if (value > room) return same;
return { ok: true, weights: moveWeight(weights, locks, key, value) };
}
/** 결과 비교값 — 서버가 셈해 준 것을 보이기만 한다. 없는 칸은 null. */
export interface InitialMetrics {
lengthM: number | null;
@@ -24,7 +24,10 @@ import {
CRITERIA_KEYS,
LOGIC_LABEL,
metricsLine,
rebalanceWeights,
canLockWeight,
derivedWeightKey,
inputWeight,
moveWeight,
relaxedLine,
violationsLine,
violationText,
@@ -33,6 +36,7 @@ import {
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";
@@ -55,6 +59,11 @@ const WEIGHT_NAME: Record<WeightKey, string> = {
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,
@@ -92,6 +101,8 @@ export interface RecommendParams {
/** 칸을 비운 채 처음 물었을 때 서버가 답한 값 — 빈 칸 placeholder. 여기서 채워 간다. */
defaults: Partial<Record<CriteriaKey, number>>;
weights: RouteInitialWeights;
/** 비중 · 고정 상태 — 편집 창이 사는 동안 모달을 닫았다 열어도 남는다. 여기서 고쳐 간다. */
weightMemory: { weights: RouteInitialWeights | null; locks: WeightLocks };
planMinRadiusM: number;
/** 지도 바탕 — 예상노선(원본 점 묶음) · 창을 연 노선(노드). */
expected: Vertex[];
@@ -108,7 +119,10 @@ export interface RecommendParams {
export function openRecommendModal(params: RecommendParams): void {
let logic: RouteInitialLogic = "follow";
let weights: RouteInitialWeights = { ...params.weights };
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;
/** 마지막 미리 보기 뒤 슬라이더 · 제약 칸을 만졌다 — 미리 본 노선이 칸 값과 다르다. */
@@ -156,12 +170,14 @@ export function openRecommendModal(params: RecommendParams): void {
<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>
(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]} 비중" />
<span>${WEIGHT_NAME[key]}</span></label>`,
<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>
@@ -232,9 +248,31 @@ export function openRecommendModal(params: RecommendParams): void {
pill.disabled = busy;
});
weightsBox.hidden = logic !== "weighted";
const derived = derivedWeightKey(locks);
for (const key of WEIGHT_KEYS) {
q<HTMLInputElement>(`[data-weight="${key}"]`).value = String(weights[key]);
q<HTMLOutputElement>(`[data-weight-out="${key}"]`).value = `${weights[key]}`;
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}`;
@@ -245,8 +283,8 @@ export function openRecommendModal(params: RecommendParams): void {
input.disabled = busy;
});
rerun.hidden = !dirty;
rerun.disabled = busy;
applyButton.disabled = busy || dirty || !preview;
rerun.disabled = busy || invalid.size > 0;
applyButton.disabled = busy || dirty || !preview || invalid.size > 0;
line("metrics").textContent = preview
? preview.metrics
? metricsLine(preview.metrics)
@@ -269,6 +307,10 @@ export function openRecommendModal(params: RecommendParams): void {
}
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();
@@ -314,17 +356,59 @@ export function openRecommendModal(params: RecommendParams): void {
void run();
});
});
const remember = (): void => {
params.weightMemory.weights = { ...weights };
};
body.querySelectorAll<HTMLInputElement>("[data-weight]").forEach((input) => {
input.addEventListener("input", () => {
weights = rebalanceWeights(
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;
@@ -163,13 +163,73 @@
accent-color: var(--color-primary);
}
.b05-routerec__bar output {
/* 값 칸 — 슬라이더 옆에서 숫자를 직접 넣는다. 합 100 을 못 맞추면 빨간 테두리. */
.b05-routerec__num {
width: 100%;
min-width: 3.2em;
padding: 2px 4px;
border: 1px solid var(--color-border);
border-radius: var(--radius-4, 4px);
background: var(--color-surface);
color: var(--color-text-body);
font-size: var(--text-body);
font-weight: 600;
text-align: center;
appearance: textfield;
-moz-appearance: textfield;
}
.b05-routerec__bar span {
.b05-routerec__num::-webkit-outer-spin-button,
.b05-routerec__num::-webkit-inner-spin-button {
margin: 0;
-webkit-appearance: none;
}
.b05-routerec__num:disabled {
opacity: 0.6;
}
.b05-routerec__num.is-invalid {
border-color: var(--color-danger, #dc2626);
box-shadow: 0 0 0 1px var(--color-danger, #dc2626);
}
/* 고정 단추 — 자물쇠. 눌린 것 = 고정 · 저절로 정해진 비중은 눌린 채 꺼져(잠김 표시). */
.b05-routerec__lock {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 26px;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-4, 4px);
background: var(--color-surface);
color: var(--color-text-secondary);
cursor: pointer;
}
.b05-routerec__lock:hover:not(:disabled) {
border-color: var(--color-primary);
}
.b05-routerec__lock.is-on {
border-color: var(--color-primary);
background: var(--color-primary);
color: var(--color-on-primary, #fff);
}
.b05-routerec__lock:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.b05-routerec__lock.is-auto {
opacity: 0.8;
border-style: dashed;
}
.b05-routerec__bar > span {
color: var(--color-text-secondary);
}
@@ -255,6 +255,49 @@ trail.commit(step(1, { logic: "earthwork" }));
trail.commit(step(2, null));
const historyRecs = [trail.undo(), trail.undo(), trail.redo(), trail.redo()].map((one) => one.rec);
// ⑧ 비중 고정(계획서 26-2c) — 고정한 값은 안 움직이고 나머지가 합 100 을 맞춘다.
const W = { expected: 70, earthwork: 15, grade: 15 };
const L = (...keys) => ({ expected: false, earthwork: false, grade: false, ...Object.fromEntries(keys.map((k) => [k, true])) });
const sweep = [];
for (const locks of [L(), L("expected"), L("earthwork"), L("grade"), L("expected", "grade")]) {
for (const key of ["expected", "earthwork", "grade"]) {
for (let v = -5; v <= 105; v += 5) {
const out = model.moveWeight(W, locks, key, v);
sweep.push({
sum: out.expected + out.earthwork + out.grade,
lockedSame: Object.keys(locks).every((k) => !locks[k] || out[k] === W[k]),
range: [out.expected, out.earthwork, out.grade].every((n) => Number.isInteger(n) && n >= 0 && n <= 100),
});
}
}
}
const weightLocks = {
freeThree: model.moveWeight(W, L(), "expected", 40),
lockExpected: model.moveWeight(W, L("expected"), "earthwork", 20),
lockExpectedClamp: model.moveWeight(W, L("expected"), "earthwork", 90),
twoLocked: model.moveWeight({ expected: 70, earthwork: 20, grade: 10 }, L("expected", "earthwork"), "grade", 50),
movingLocked: model.moveWeight(W, L("expected"), "expected", 10),
derived: [model.derivedWeightKey(L()), model.derivedWeightKey(L("expected")), model.derivedWeightKey(L("expected", "earthwork"))],
canLock: {
none: model.canLockWeight(L(), "grade"),
one: model.canLockWeight(L("expected"), "grade"),
two: model.canLockWeight(L("expected", "earthwork"), "grade"),
unlockOfTwo: model.canLockWeight(L("expected", "earthwork"), "expected"),
},
input: {
free: model.inputWeight(W, L(), "earthwork", 55),
lockedOk: model.inputWeight(W, L("expected"), "earthwork", 20),
lockedTooBig: model.inputWeight(W, L("expected"), "earthwork", 40),
fraction: model.inputWeight(W, L(), "earthwork", 1.5),
negative: model.inputWeight(W, L(), "earthwork", -1),
over: model.inputWeight(W, L(), "earthwork", 101),
nan: model.inputWeight(W, L(), "earthwork", Number.NaN),
onLocked: model.inputWeight(W, L("earthwork"), "earthwork", 15),
derivedSame: model.inputWeight({ expected: 70, earthwork: 20, grade: 10 }, L("expected", "earthwork"), "grade", 10),
derivedOther: model.inputWeight({ expected: 70, earthwork: 20, grade: 10 }, L("expected", "earthwork"), "grade", 30),
},
};
// ④ 캐시 왕복 — 결과 통 프로젝트 칸. 꼴이 틀린 값은 안 읽는다.
model.writeInitialChoice("p-1", plain);
const readBack = model.readInitialChoice("p-1");
@@ -296,5 +339,7 @@ process.stdout.write(
initialRoute,
mapLines,
historyRecs,
sweep,
weightLocks,
}),
);
@@ -213,3 +213,36 @@ def test_노선_추천_다듬기_23_3():
assert "calc(var(--z-modal, 1000) + 2)" in css # 라벨 패널(+1) 위
assert "writing-mode: vertical-lr" in css
assert 'backdropClass: "b05-routerec-backdrop"' in read("B05_Profile_UI_RouteEdit_Recommend.ts")
def test_비중_고정_합_100_규칙(results):
"""고정한 값은 안 움직이고 나머지가 합 100 — 어떤 고정 · 어떤 값으로 옮겨도 정수 0~100 · 합 100."""
assert all(row["sum"] == 100 and row["lockedSame"] and row["range"] for row in results["sweep"])
w = results["weightLocks"]
# 안 고정: 지금 비율대로 나눔(기존 규칙)
assert w["freeThree"] == {"expected": 40, "earthwork": 30, "grade": 30}
# 예상 70 고정 → 토공을 끌면 기울기만 따라 바뀜
assert w["lockExpected"] == {"expected": 70, "earthwork": 20, "grade": 10}
# 남는 몫(30)을 넘게 끌면 잘림
assert w["lockExpectedClamp"] == {"expected": 70, "earthwork": 30, "grade": 0}
# 둘 고정 → 셋째는 저절로 정해져 안 움직임
assert w["twoLocked"] == {"expected": 70, "earthwork": 20, "grade": 10}
assert w["movingLocked"] == {"expected": 70, "earthwork": 15, "grade": 15}
assert w["derived"] == [None, None, "grade"]
# 셋 다 고정은 막음 · 푸는 것은 늘 됨
assert w["canLock"] == {"none": True, "one": True, "two": False, "unlockOfTwo": True}
def test_비중_값_칸_직접_입력(results):
"""0~100 정수 · 고정 규칙 아래 합 100 을 못 맞추면 받지 않음(빨간 테두리 · 안 보냄)."""
i = results["weightLocks"]["input"]
assert i["free"] == {"ok": True, "weights": {"expected": 37, "earthwork": 55, "grade": 8}}
assert i["lockedOk"] == {"ok": True, "weights": {"expected": 70, "earthwork": 20, "grade": 10}}
for name in ("lockedTooBig", "fraction", "negative", "over", "nan", "onLocked"):
assert i[name]["ok"] is False, name
assert i[name]["weights"] == {"expected": 70, "earthwork": 15, "grade": 15}, name
assert i["derivedSame"]["ok"] is True
assert i["derivedOther"] == {
"ok": False,
"weights": {"expected": 70, "earthwork": 20, "grade": 10},
}