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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
const WEIGHT_NAME: Record<WeightKey, string> = {
|
||||
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<HTMLOutputElement>(`[data-weight-out="${key}"]`)!.value = `${weights[key]}`;
|
||||
}
|
||||
box.querySelector<HTMLElement>("[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),
|
||||
};
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
// ④ 캐시 왕복 — 결과 통 프로젝트 칸. 꼴이 틀린 값은 안 읽는다.
|
||||
|
||||
@@ -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 · 계류 이격 ≥ — (후보 · 사용자 확정 대기)"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user