fix(B05): 초기노선 갈래 화면을 서버 길 모양(PLAN 20장 API 줄)에 맞춤

- 요청 { mode, weights?{expected, earthwork, grade} } — logic · slope 가정을 버림
- 비교값 avg_grade_pct · over_limit.total · offset_avg_m, 기준값 pending(비면 확정) 읽기
- [확인] 몸통 initial 도 { mode, weights }
- 시험 test_b05_routeedit_initial.py 를 서버 답 모양으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sCeZz8z8YnbovsKgn7HK7
This commit is contained in:
2026-09-29 10:03:38 +09:00
co-authored by Claude Opus 5.5
parent 0494079111
commit 4366cd3f03
5 changed files with 68 additions and 56 deletions
@@ -19,8 +19,8 @@ export const LOGIC_LABEL: Record<RouteInitialLogic, string> = {
};
/** 비중 기본값(%) — 계획서 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<string, unknown>, ...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<string, unknown>, 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<string, unknown>;
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<string, unknown>;
const c = (body.criteria ?? {}) as Record<string, unknown>;
const over = (m.over_limit ?? {}) as Record<string, unknown>;
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 =>