diff --git a/B05_Profile/B05_Profile_Api_RouteInitial.ts b/B05_Profile/B05_Profile_Api_RouteInitial.ts index 1584a7f1..be108a83 100644 --- a/B05_Profile/B05_Profile_Api_RouteInitial.ts +++ b/B05_Profile/B05_Profile_Api_RouteInitial.ts @@ -2,11 +2,11 @@ * B05_Profile_Api_RouteInitial.ts * 초기노선 갈래 요청(계획서 20) — 로직 하나를 골라 서버가 새 계획노선 노드를 낸다. * - * POST /projects/{id}/route/initial { logic, weights? } → 노드 · 비교값 · 공통 제약 기준값 + * POST /projects/{id}/route/initial { mode, weights? } → 노드 · 비교값 · 공통 제약 기준값 * * 계산 자리는 **서버 단독**(CLAUDE.md 5장 ③) — 화면은 부르고, 받은 노드를 편집 캐시에 싣고, - * 받은 비교값·기준값을 보이기만 한다. 서버 길은 sub3 몫이라 모양이 오면 `normalizeRouteInitial` - * (`_RouteEdit_Initial_Model`) 한 곳만 맞춘다. + * 받은 비교값·기준값을 보이기만 한다. 모양은 계획서 20 「API」 줄(서버 `initial_route`) — + * 답을 펴는 자리는 `normalizeRouteInitial`(`_RouteEdit_Initial_Model`) 한 곳이다. * ========================================================================== */ import { requestJson } from "./B05_Profile_Api_Replan"; @@ -21,11 +21,11 @@ export type RouteInitialLogic = "follow" | "earthwork" | "gentle" | "weighted"; export interface RouteInitialWeights { expected: number; earthwork: number; - slope: number; + grade: number; } export interface RouteInitialRequest { - logic: RouteInitialLogic; + mode: RouteInitialLogic; weights?: RouteInitialWeights; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts index 4b344139..c98242a0 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts @@ -35,7 +35,7 @@ import { const WEIGHT_NAME: Record = { expected: "예상", earthwork: "토공", - slope: "기울기", + grade: "기울기", }; export interface InitialPanelParams { @@ -51,7 +51,7 @@ export interface InitialPanel { /** 노선을 읽었다 — 창을 연 편집값과 「고친 노선인가」를 받아 단추를 연다. */ ready: (opening: RouteEditSnapshot, edited: boolean) => void; /** [확인]에 실어 보낼 지금 초기값. */ - current: () => { logic: RouteInitialLogic; weights: RouteInitialWeights | null }; + current: () => { mode: RouteInitialLogic; weights: RouteInitialWeights | null }; /** [취소] — 이 창에서 바꾼 초기값을 창을 열 때 것으로 되돌린다. */ cancel: () => void; /** [확인] 뒤 결과 캐시를 비웠다 — 초기값은 새 노선의 출발점이라 다시 적는다. */ @@ -120,7 +120,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { box.querySelector(`[data-weight-out="${key}"]`)!.value = `${weights[key]}`; } box.querySelector("[data-weight-sum]")!.textContent = - `합 ${weights.expected + weights.earthwork + weights.slope}`; + `합 ${weights.expected + weights.earthwork + weights.grade}`; buttons.forEach((button) => { button.disabled = busy || !opening; }); @@ -136,7 +136,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { nowLine.textContent = `${choiceLabel(logic, sent)} 셈하는 중…`; try { const response = await fetchRouteInitial(projectId, { - logic, + mode: logic, ...(sent ? { weights: sent } : {}), }); if (!box.isConnected) return; // 그 사이 창이 닫혔다. @@ -195,7 +195,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { edited = wasEdited; render(); }, - current: () => ({ logic: choice?.logic ?? "follow", weights: choice?.weights ?? null }), + current: () => ({ mode: choice?.logic ?? "follow", weights: choice?.weights ?? null }), cancel: () => writeInitialChoice(projectId, openingChoice), keep: () => writeInitialChoice(projectId, choice), }; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts index bdf220cc..feeac8bd 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts @@ -19,8 +19,8 @@ export const LOGIC_LABEL: Record = { }; /** 비중 기본값(%) — 계획서 20 사용자 확정 50/25/25. */ -export const DEFAULT_WEIGHTS: RouteInitialWeights = { expected: 50, earthwork: 25, slope: 25 }; -export const WEIGHT_KEYS = ["expected", "earthwork", "slope"] as const; +export const DEFAULT_WEIGHTS: RouteInitialWeights = { expected: 50, earthwork: 25, grade: 25 }; +export const WEIGHT_KEYS = ["expected", "earthwork", "grade"] as const; export type WeightKey = (typeof WEIGHT_KEYS)[number]; /** 한 비중을 옮기면 나머지 둘이 **지금 비율대로** 나눠 합 100 을 지킨다(정수 %). */ @@ -35,7 +35,7 @@ export function rebalanceWeights( const pool = weights[first] + weights[second]; const firstShare = pool > 0 ? Math.round((rest * weights[first]) / pool) : Math.round(rest / 2); const out = { ...weights, [key]: fixed, [first]: firstShare, [second]: rest - firstShare }; - return { expected: out.expected, earthwork: out.earthwork, slope: out.slope }; + return { expected: out.expected, earthwork: out.earthwork, grade: out.grade }; } /** 결과 비교값 — 서버가 셈해 준 것을 보이기만 한다. 없는 칸은 null. */ @@ -49,7 +49,7 @@ export interface InitialMetrics { offsetM: number | null; } -/** 공통 제약 기준값 — 서버가 준 값. `confirmed` 가 거짓이면 지식DB 후보(사용자 확정 대기). */ +/** 공통 제약 기준값 — 서버가 준 값. `pending` 이 남아 `confirmed` 가 거짓이면 지식DB 후보. */ export interface InitialCriteria { maxGradePct: number | null; minRadiusM: number | null; @@ -69,14 +69,7 @@ export interface InitialChoice { const num = (value: unknown): number | null => typeof value === "number" && Number.isFinite(value) ? value : null; -/** 여러 이름 중 먼저 있는 숫자 — 서버 낱말이 정해지기 전이라 흔한 이름을 함께 본다. */ -function pick(source: Record, ...names: string[]): number | null { - for (const name of names) { - const value = num(source[name]); - if (value !== null) return value; - } - return null; -} +const pick = (source: Record, name: string): number | null => num(source[name]); /** 노드 목록을 편집값 한 벌로 편다 — 곡선 성분이 있으면 `/route/plan` 과 같은 규칙으로. */ function snapshotOf(nodes: unknown[], curves: unknown): RouteEditSnapshot { @@ -105,39 +98,37 @@ export function normalizeRouteInitial( logic: RouteInitialLogic, weights: RouteInitialWeights | null, ): InitialChoice { + // 모양은 계획서 20 「API」 줄 — 노드 · 곡선은 `/route/plan` 과 같은 꼴이다. const body = (response ?? {}) as Record; - const nodes = Array.isArray(body.nodes) - ? body.nodes - : Array.isArray(body.vertices) - ? body.vertices - : []; - const snapshot = snapshotOf(nodes, body.curves); + const snapshot = snapshotOf(Array.isArray(body.nodes) ? body.nodes : [], body.curves); if (snapshot.planned.length < 2 || snapshot.planned.some(([x, y]) => !Number.isFinite(x + y))) { throw new Error("서버가 낸 초기노선에 노드가 모자랍니다."); } const m = (body.metrics ?? {}) as Record; const c = (body.criteria ?? {}) as Record; + const over = (m.over_limit ?? {}) as Record; return { logic, weights, snapshot, metrics: body.metrics ? { - lengthM: pick(m, "length_m", "total_length_m"), - maxGradePct: pick(m, "max_grade_pct", "max_slope_pct"), - meanGradePct: pick(m, "mean_grade_pct", "mean_slope_pct", "avg_grade_pct"), - cutM3: pick(m, "cut_m3", "cut_volume_m3"), - fillM3: pick(m, "fill_m3", "fill_volume_m3"), - overLimitCount: pick(m, "over_limit_count", "violation_count", "exceed_count"), - offsetM: pick(m, "offset_mean_m", "expected_offset_m", "offset_m"), + lengthM: pick(m, "length_m"), + maxGradePct: pick(m, "max_grade_pct"), + meanGradePct: pick(m, "avg_grade_pct"), + cutM3: pick(m, "cut_m3"), + fillM3: pick(m, "fill_m3"), + overLimitCount: pick(over, "total"), + offsetM: pick(m, "offset_avg_m"), } : null, criteria: body.criteria ? { - maxGradePct: pick(c, "max_grade_pct", "max_slope_pct"), + maxGradePct: pick(c, "max_grade_pct"), minRadiusM: pick(c, "min_radius_m"), - streamOffsetM: pick(c, "stream_offset_m", "stream_clearance_m"), - confirmed: c.confirmed === true, + streamOffsetM: pick(c, "stream_offset_m"), + // 확정 대기 칸 이름 목록이 비어야 확정이다. + confirmed: Array.isArray(c.pending) && c.pending.length === 0, } : null, }; @@ -146,7 +137,7 @@ export function normalizeRouteInitial( /** 「현재 초기값: ○○」의 ○○ — 비중 계산이면 비중까지. */ export function choiceLabel(logic: RouteInitialLogic, weights: RouteInitialWeights | null): string { if (logic !== "weighted" || !weights) return LOGIC_LABEL[logic]; - return `${LOGIC_LABEL.weighted} ${weights.expected}/${weights.earthwork}/${weights.slope}`; + return `${LOGIC_LABEL.weighted} ${weights.expected}/${weights.earthwork}/${weights.grade}`; } const fixed = (value: number | null, digits: number, unit: string): string => diff --git a/resources/tester/helper_b05_routeedit_initial.cjs b/resources/tester/helper_b05_routeedit_initial.cjs index 50c3545d..c2d578ea 100644 --- a/resources/tester/helper_b05_routeedit_initial.cjs +++ b/resources/tester/helper_b05_routeedit_initial.cjs @@ -55,10 +55,10 @@ const { DEFAULT_WEIGHTS } = model; const moves = [ model.rebalanceWeights(DEFAULT_WEIGHTS, "expected", 70), model.rebalanceWeights(DEFAULT_WEIGHTS, "earthwork", 40), - model.rebalanceWeights({ expected: 100, earthwork: 0, slope: 0 }, "expected", 60), - model.rebalanceWeights({ expected: 20, earthwork: 30, slope: 50 }, "slope", 100), - model.rebalanceWeights({ expected: 33, earthwork: 33, slope: 34 }, "earthwork", 33.4), - model.rebalanceWeights(DEFAULT_WEIGHTS, "slope", -5), + model.rebalanceWeights({ expected: 100, earthwork: 0, grade: 0 }, "expected", 60), + model.rebalanceWeights({ expected: 20, earthwork: 30, grade: 50 }, "grade", 100), + model.rebalanceWeights({ expected: 33, earthwork: 33, grade: 34 }, "earthwork", 33.4), + model.rebalanceWeights(DEFAULT_WEIGHTS, "grade", -5), ]; // ② 서버 답 펴기 — 노드 목록 꼴과 `/route/plan` 꼴(노드+곡선) 둘 다. @@ -69,16 +69,25 @@ const plain = model.normalizeRouteInitial( { x: 50, y: 10, curve: true, radius_m: 30 }, { x: 100, y: 0, curve: false }, ], + // 계획서 20 「API」 줄 모양. metrics: { length_m: 1234.56, max_grade_pct: 12.34, - mean_grade_pct: 6.1, + avg_grade_pct: 6.1, cut_m3: 1500.4, fill_m3: 900, - over_limit_count: 2, - offset_mean_m: 3.25, + over_limit: { grade: 1, radius: 0, stream: 1, total: 2 }, + offset_avg_m: 3.25, + offset_max_m: 9.8, + }, + criteria: { + max_grade_pct: 14, + min_radius_m: 12, + stream_offset_m: 5, + road_width_m: 4, + corridor_m: 100, + pending: ["max_grade_pct", "stream_offset_m"], }, - criteria: { max_grade_pct: 14, min_radius_m: 12, stream_offset_m: 5 }, }, "earthwork", null, @@ -105,7 +114,7 @@ const planShape = model.normalizeRouteInitial( ], }, "weighted", - { expected: 60, earthwork: 20, slope: 20 }, + { expected: 60, earthwork: 20, grade: 20 }, ); let tooShort = ""; try { @@ -117,12 +126,23 @@ try { // ③ 글 — 현재 초기값 · 비교값 · 기준값. const labels = { follow: model.choiceLabel("follow", null), - weighted: model.choiceLabel("weighted", { expected: 60, earthwork: 20, slope: 20 }), + weighted: model.choiceLabel("weighted", { expected: 60, earthwork: 20, grade: 20 }), metrics: model.metricsLine(plain.metrics), metricsNone: model.metricsLine(null), criteria: model.criteriaLine(plain.criteria, 0), criteriaConfirmed: model.criteriaLine({ ...plain.criteria, confirmed: true }, 0), criteriaFallback: model.criteriaLine(null, 15), + pendingEmpty: model.normalizeRouteInitial( + { + nodes: [ + { x: 0, y: 0 }, + { x: 9, y: 9 }, + ], + criteria: { pending: [] }, + }, + "gentle", + null, + ).criteria.confirmed, }; // ④ 캐시 왕복 — 결과 통 프로젝트 칸. 꼴이 틀린 값은 안 읽는다. diff --git a/resources/tester/test_b05_routeedit_initial.py b/resources/tester/test_b05_routeedit_initial.py index f7a7f254..42a03411 100644 --- a/resources/tester/test_b05_routeedit_initial.py +++ b/resources/tester/test_b05_routeedit_initial.py @@ -32,19 +32,19 @@ def results(): def test_비중은_늘_합_100_정수(results): for weights in results["moves"]: - assert list(weights) == ["expected", "earthwork", "slope"] + assert list(weights) == ["expected", "earthwork", "grade"] assert sum(weights.values()) == 100 assert all(isinstance(v, int) and 0 <= v <= 100 for v in weights.values()) def test_비중은_나머지를_지금_비율대로_나눔(results): moves = results["moves"] - assert moves[0] == {"expected": 70, "earthwork": 15, "slope": 15} - assert moves[1] == {"expected": 40, "earthwork": 40, "slope": 20} + assert moves[0] == {"expected": 70, "earthwork": 15, "grade": 15} + assert moves[1] == {"expected": 40, "earthwork": 40, "grade": 20} # 나머지가 모두 0 이면 반씩. - assert moves[2] == {"expected": 60, "earthwork": 20, "slope": 20} - assert moves[3] == {"expected": 0, "earthwork": 0, "slope": 100} - assert moves[5]["slope"] == 0 + assert moves[2] == {"expected": 60, "earthwork": 20, "grade": 20} + assert moves[3] == {"expected": 0, "earthwork": 0, "grade": 100} + assert moves[5]["grade"] == 0 def test_노드_목록_꼴을_편집값으로(results): @@ -63,7 +63,7 @@ def test_노선계획_꼴도_같은_규칙으로(results): assert snap["planned"] == [[0, 0], [40, 30], [90, 30]] assert snap["curveOn"] == [False, True, False] assert snap["curveRadius"] == [None, 25, None] - assert shape["weights"] == {"expected": 60, "earthwork": 20, "slope": 20} + assert shape["weights"] == {"expected": 60, "earthwork": 20, "grade": 20} assert shape["metrics"] is None and shape["criteria"] is None @@ -84,6 +84,7 @@ def test_비교값과_기준값_글(results): "공통 제약 종단기울기 ≤ 14.0% · 최소 R 12m · 계류 이격 ≥ 5.0m (후보 · 사용자 확정 대기)" ) assert labels["criteriaConfirmed"].endswith("5.0m") + assert labels["pendingEmpty"] is True assert labels["criteriaFallback"] == ( "공통 제약 종단기울기 ≤ — · 최소 R 15m · 계류 이격 ≥ — (후보 · 사용자 확정 대기)" )