From 559d445e4ee4fd98b2be4c11b624f4810cfdafdb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 30 Sep 2026 07:36:32 +0900 Subject: [PATCH] auto: 2026-09-30 07:36 (ESD_LAPTOP) --- .../B05_Profile_UI_RouteEdit_Initial.ts | 7 ++ .../B05_Profile_UI_RouteEdit_Initial_Model.ts | 62 ++++++++++ .../B05_Profile_UI_RouteEdit_Recommend.ts | 106 ++++++++++++++++-- .../B05_Profile_UI_Style_RouteRecommend.css | 64 ++++++++++- .../tester/helper_b05_routeedit_initial.cjs | 45 ++++++++ .../tester/test_b05_routeedit_initial.py | 33 ++++++ 6 files changed, 304 insertions(+), 13 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts index eaa7f8e4..8c440275 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts @@ -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 | 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]), diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts index a8ad81c6..4b5ea6c6 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts @@ -51,6 +51,68 @@ export function rebalanceWeights( return { expected: out.expected, earthwork: out.earthwork, grade: out.grade }; } +/** 비중마다 고정 표시 — 고정한 값은 다른 비중을 움직여도 안 바뀐다. */ +export type WeightLocks = Record; + +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; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts index bb43b44a..f86fe93d 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts @@ -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 = { grade: "기울기", }; +/** 고정 단추 그림 — 자물쇠(글자 색을 따라 밝음 · 어두움에서 보인다). */ +const LOCK_ICON = ``; + /** 공통 제약 칸 — 표시 이름 · 단위 · 서버가 준 값(`InitialCriteria`)에서 그 칸을 꺼내는 법. */ const CRITERIA_FIELD: Record< CriteriaKey, @@ -92,6 +101,8 @@ export interface RecommendParams { /** 칸을 비운 채 처음 물었을 때 서버가 답한 값 — 빈 칸 placeholder. 여기서 채워 간다. */ defaults: Partial>; 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(); let preview: InitialChoice | null = null; let busy = false; /** 마지막 미리 보기 뒤 슬라이더 · 제약 칸을 만졌다 — 미리 본 노선이 칸 값과 다르다. */ @@ -156,12 +170,14 @@ export function openRecommendModal(params: RecommendParams): void {