feat(B05): 노선 추천 — 알약 여섯(짧은 노선 · 흙 균형) · 남는 흙 비교값 · 같은 노선 알림 줄 · 비중 고정 시험 (PLAN 23-4c 화면 · 26-2c)

- 알약: 예상노선 추적 · 토공 최소 · 기울기 순한 · 짧은 노선(short) · 흙 균형(balance) · 비중 계산 — 짧은 노선 · 흙 균형은 비중 없음
- 비교값에 「남는 흙」(surplus_m3 · + 남음 / − 모자람)
- 응답 similar · band_width_m → 비교값 아래 「이 지형은 제약 안에서 갈 길이 폭 …」 한 줄 + 흐린 안내 · 없으면 줄 숨김
- 시험: 가짜 응답 · 여섯 이름 · 남는 흙 · 알림 줄 · 비중 계산 사유 · 왕복 시험에 short · balance 더함

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 08:10:09 +09:00
co-authored by Claude Sonnet 5.5
parent a66df6d887
commit 2deef5a5da
7 changed files with 208 additions and 2 deletions
+2 -1
View File
@@ -15,7 +15,8 @@ import { requestJson } from "./B05_Profile_Api_Replan";
const INITIAL_TIMEOUT_MS = 5 * 60 * 1000;
/** 로직 이름 — 서버와 같은 낱말을 쓴다. `follow` 는 예상노선 추적(처음 초기값). */
export type RouteInitialLogic = "follow" | "earthwork" | "gentle" | "weighted";
export type RouteInitialLogic =
"follow" | "earthwork" | "gentle" | "short" | "balance" | "weighted";
/** 비중 계산의 세 비중(%) — 합 100. */
export interface RouteInitialWeights {
@@ -19,6 +19,8 @@ export const LOGIC_LABEL: Record<RouteInitialLogic, string> = {
follow: "예상노선 추적",
earthwork: "토공 최소",
gentle: "기울기 순한",
short: "짧은 노선",
balance: "흙 균형",
weighted: "비중 계산",
};
@@ -120,6 +122,8 @@ export interface InitialMetrics {
meanGradePct: number | null;
cutM3: number | null;
fillM3: number | null;
/** 남는 흙(㎥) = 절토 − 성토 — + 남음 / − 모자람. 서버 `surplus_m3`. */
surplusM3: number | null;
overLimitCount: number | null;
offsetM: number | null;
/** 제약 못 지킨 구간 — 서버 `metrics.violations`. 칸이 없으면 null(옛 답). */
@@ -155,6 +159,16 @@ export interface InitialChoice {
criteriaOverrides: RouteInitialCriteria | null;
/** 제약을 다 지키는 길이 없어 기울기 상한을 풀었나 — 풀었으면 그 값(%). */
terrainBoundPct?: number | null;
/** 이 노선과 거의 같은(10 m 안) 다른 갈래 — 서버 `similar`. 없으면 null(옛 답 · 묶음 없음). */
similar?: SimilarRoute[] | null;
/** 제약 안에서 갈 수 있는 길의 띠 폭 어림(m) — 서버 `band_width_m`. */
bandWidthM?: number | null;
}
/** 거의 같은 노선인 다른 갈래 하나 — 두 노선 사이 가장 먼 거리(m). */
export interface SimilarRoute {
logic: RouteInitialLogic;
gapM: number;
}
const num = (value: unknown): number | null =>
@@ -229,6 +243,19 @@ export function mapPaths(
}));
}
/** 서버 `similar` — 갈래 이름을 아는 것만 담는다. 목록이 아니면 null · 빈 목록이면 빈 배열. */
function parseSimilar(raw: unknown): SimilarRoute[] | null {
if (!Array.isArray(raw)) return null;
const out: SimilarRoute[] = [];
for (const item of raw as Array<Record<string, unknown>>) {
const logic = typeof item?.logic === "string" ? item.logic : "";
const gap = num(item?.max_gap_m);
if (logic in LOGIC_LABEL && gap !== null)
out.push({ logic: logic as RouteInitialLogic, gapM: gap });
}
return out;
}
/** 서버 답을 편다. 노드가 두 개 못 되면 던진다(적용할 노선이 없다).
* `criteriaOverrides` 는 이 요청에 실어 보낸 제약 겹침(빈 칸 뺀 것) — 캐시·재열기에 남긴다. */
export function normalizeRouteInitial(
@@ -257,6 +284,7 @@ export function normalizeRouteInitial(
meanGradePct: pick(m, "avg_grade_pct"),
cutM3: pick(m, "cut_m3"),
fillM3: pick(m, "fill_m3"),
surplusM3: pick(m, "surplus_m3"),
overLimitCount: pick(over, "total"),
offsetM: pick(m, "offset_avg_m"),
violations: Array.isArray(m.violations)
@@ -271,6 +299,8 @@ export function normalizeRouteInitial(
}
: null,
terrainBoundPct: body.relaxed === true ? num(body.terrain_bound_pct) : null,
similar: parseSimilar(body.similar),
bandWidthM: num(body.band_width_m),
criteria: body.criteria
? {
maxGradePct: pick(c, "max_grade_pct"),
@@ -351,6 +381,39 @@ const fixed = (value: number | null, digits: number, unit: string): string =>
? "—"
: `${value.toLocaleString("ko-KR", { maximumFractionDigits: digits, minimumFractionDigits: digits })}${unit}`;
/** 남는 흙(절토 − 성토) — 부호를 늘 붙여 + 남음 / − 모자람으로 읽힌다. */
export function surplusText(surplusM3: number): string {
const rounded = Math.round(surplusM3);
if (rounded === 0) return "0㎥(균형)";
const sign = rounded > 0 ? "+" : "−";
return `${sign}${Math.abs(rounded).toLocaleString("ko-KR")}㎥(${rounded > 0 ? "남음" : "모자람"})`;
}
/** 조사 「과 / 와」 — 받침이 있으면 과. */
const withParticle = (name: string): string => {
const code = name.charCodeAt(name.length - 1);
const hasFinal = code >= 0xac00 && code <= 0xd7a3 && (code - 0xac00) % 28 !== 0;
return `${name}${hasFinal ? "과" : "와"}`;
};
/** 같은 노선 알림 한 줄 — 이 지형은 제약 안에서 갈 길이 한 띠뿐일 때. 없으면 빈 글(줄을 숨긴다).
* 「이 지형은 제약 안에서 갈 길이 폭 13.5m 한 띠뿐 — 토공 최소 · 짧은 노선과 거의 같은 노선(가장 먼 거리 6.1m)」 */
export function similarLine(choice: InitialChoice | null): string {
const similar = choice?.similar;
if (!choice || !similar || similar.length === 0) return "";
const names = similar.map((item) => LOGIC_LABEL[item.logic]).join(" · ");
const gap = Math.max(...similar.map((item) => item.gapM));
const band =
choice.bandWidthM === null || choice.bandWidthM === undefined
? ""
: `폭 ${fixed(choice.bandWidthM, 1, "m")} `;
return `이 지형은 제약 안에서 갈 길이 ${band}한 띠뿐 — ${withParticle(names)} 거의 같은 노선(가장 먼 거리 ${fixed(gap, 1, "m")})`;
}
/** 같은 노선 알림 아래 흐린 글 — 제약을 바꾸면 갈래가 벌어질 수 있다. */
export const SIMILAR_HINT =
"기울기 · 최소R · 계류 제약 값을 바꾸면 갈래가 서로 벌어질 수 있습니다(다시 셈).";
/** 결과 비교값 한 줄 — 연장 · 최대/평균 기울기 · 절성토량 · 기준 초과 구간 · 이격. */
export function metricsLine(metrics: InitialMetrics | null): string {
if (!metrics) return "비교값 — 로직을 누르면 서버가 셈해 보입니다.";
@@ -358,6 +421,7 @@ export function metricsLine(metrics: InitialMetrics | null): string {
`연장 ${fixed(metrics.lengthM, 1, "m")}`,
`기울기 최대 ${fixed(metrics.maxGradePct, 1, "%")} · 평균 ${fixed(metrics.meanGradePct, 1, "%")}`,
`절토 ${fixed(metrics.cutM3, 0, "㎥")} · 성토 ${fixed(metrics.fillM3, 0, "㎥")}`,
...(metrics.surplusM3 === null ? [] : [`남는 흙 ${surplusText(metrics.surplusM3)}`]),
`기준 초과 ${metrics.overLimitCount === null ? "—" : `${metrics.overLimitCount}구간`}`,
`이격 ${fixed(metrics.offsetM, 1, "m")}`,
].join(" · ");
@@ -29,6 +29,8 @@ import {
inputWeight,
moveWeight,
relaxedLine,
SIMILAR_HINT,
similarLine,
violationsLine,
violationText,
WEIGHT_KEYS,
@@ -44,12 +46,21 @@ import "./B05_Profile_UI_Style_RouteRecommend.css";
type Vertex = [number, number];
const PILLS: RouteInitialLogic[] = ["follow", "earthwork", "gentle", "weighted"];
const PILLS: RouteInitialLogic[] = [
"follow",
"earthwork",
"gentle",
"short",
"balance",
"weighted",
];
const PILL_TITLE: Record<RouteInitialLogic, string> = {
follow: "전처리가 만든 기본 노선 — 예상노선을 따라갑니다",
earthwork: "절토 · 성토가 가장 적은 노선",
gentle: "종단기울기가 가장 순한 노선",
short: "연장이 가장 짧은 노선(제약 지킴)",
balance: "절토와 성토가 비슷해 남는 흙이 가장 적은 노선",
weighted: "예상 · 토공 · 기울기 비중(합 100)으로 셈한 노선",
};
@@ -196,6 +207,8 @@ export function openRecommendModal(params: RecommendParams): void {
<div data-line="metrics"></div>
<div data-line="violations" hidden></div>
<div class="is-warn" data-line="relaxed" hidden></div>
<div data-line="similar" hidden></div>
<div class="is-dim" data-line="similar-hint" hidden></div>
<div data-line="criteria"></div>
</div>
<div class="b05-routerec__foot">
@@ -299,6 +312,11 @@ export function openRecommendModal(params: RecommendParams): void {
const relaxed = relaxedLine(preview);
line("relaxed").textContent = relaxed;
line("relaxed").hidden = !relaxed;
const similar = similarLine(preview);
line("similar").textContent = similar;
line("similar").hidden = !similar;
line("similar-hint").textContent = similar ? SIMILAR_HINT : "";
line("similar-hint").hidden = !similar;
line("criteria").textContent = preview
? criteriaLine(preview.criteria, params.planMinRadiusM)
: "";
@@ -281,6 +281,13 @@
line-height: 1.5;
}
/* 같은 노선 알림 아래 흐린 글. */
.b05-routerec__lines .is-dim {
color: var(--color-text-secondary);
font-size: var(--text-caption);
opacity: 0.8;
}
/* 상한을 풀었다는 안내 — 눈에 띄게(계획서 20). */
.b05-routerec__lines .is-warn {
color: var(--color-warning);
@@ -298,6 +298,63 @@ const weightLocks = {
},
};
// ⑨ 추천 갈래 여섯 · 남는 흙 · 같은 노선 알림(계획서 23-4c 화면).
const withExtras = (extra, metricExtra = {}) =>
model.normalizeRouteInitial(
{ ...plainResponse, ...extra, metrics: { ...plainResponse.metrics, ...metricExtra } },
"short",
null,
);
const sixth = {
labels: Object.fromEntries(
["follow", "earthwork", "gentle", "short", "balance", "weighted"].map((k) => [k, model.LOGIC_LABEL[k]]),
),
surplus: [122.4, -578.2, 0, 0.4].map((v) => model.surplusText(v)),
metricsPlus: model.metricsLine(withExtras({}, { surplus_m3: 122.4 }).metrics),
metricsMinus: model.metricsLine(withExtras({}, { surplus_m3: -578 }).metrics),
metricsNone: model.metricsLine(withExtras({}).metrics),
similarTwo: model.similarLine(
withExtras({
band_width_m: 13.5,
similar: [
{ logic: "earthwork", max_gap_m: 0.0 },
{ logic: "gentle", max_gap_m: 6.1 },
],
}),
),
similarOne: model.similarLine(
withExtras({ band_width_m: 13.54, similar: [{ logic: "balance", max_gap_m: 6.1 }] }),
),
similarNoBand: model.similarLine(withExtras({ similar: [{ logic: "weighted", max_gap_m: 9.1 }] })),
similarEmpty: model.similarLine(withExtras({ band_width_m: 13.5, similar: [] })),
similarMissing: model.similarLine(withExtras({})),
similarNull: model.similarLine(null),
similarUnknown: withExtras({ similar: [{ logic: "nope", max_gap_m: 1 }, { logic: "short", max_gap_m: "x" }] }).similar,
bandParsed: withExtras({ band_width_m: 13.5 }).bandWidthM,
hint: model.SIMILAR_HINT,
weightedViolation: model.violationsLine({
...plain.metrics,
violations: [
{
kind: "grade",
station: "No.9+0.0",
value: 29.1,
limit: 14,
reason: "비중 계산 — 예상노선 몫만큼 제약을 막지 않음(측점 사이 지반이 상한을 넘음)",
},
],
}),
weightedViolationReason: model.violationText({
kind: "grade",
station: "No.9+0.0",
value: 29.1,
limit: 14,
reason: "비중 계산 — 예상노선 몫만큼 제약을 막지 않음",
}),
shortLabel: model.choiceLabel("short", { expected: 0, earthwork: 0, grade: 0 }),
balanceLabel: model.choiceLabel("balance", null),
};
// ④ 캐시 왕복 — 결과 통 프로젝트 칸. 꼴이 틀린 값은 안 읽는다.
model.writeInitialChoice("p-1", plain);
const readBack = model.readInitialChoice("p-1");
@@ -341,5 +398,6 @@ process.stdout.write(
historyRecs,
sweep,
weightLocks,
sixth,
}),
);
@@ -246,3 +246,59 @@ def test_비중_값_칸_직접_입력(results):
"ok": False,
"weights": {"expected": 70, "earthwork": 20, "grade": 10},
}
def test_추천_갈래_여섯_이름과_가중치_없음(results):
six = results["sixth"]
assert six["labels"] == {
"follow": "예상노선 추적",
"earthwork": "토공 최소",
"gentle": "기울기 순한",
"short": "짧은 노선",
"balance": "흙 균형",
"weighted": "비중 계산",
}
# 짧은 노선 · 흙 균형은 비중이 없다(weights 0/0/0 이 와도 이름만)
assert six["shortLabel"] == "짧은 노선" and six["balanceLabel"] == "흙 균형"
page = os.path.join(HERE, "..", "..", "B05_Profile", "B05_Profile_UI_RouteEdit_Recommend.ts")
with open(page, encoding="utf-8") as handle:
text = handle.read()
order = [
text.index(f'"{name}"') for name in ("follow", "earthwork", "gentle", "short", "balance")
]
assert order == sorted(order) # 알약 순서: 추적 · 토공 · 기울기 · 짧은 · 균형 · 비중
def test_비교값_남는_흙(results):
six = results["sixth"]
assert six["surplus"] == ["+122㎥(남음)", "−578㎥(모자람)", "0㎥(균형)", "0㎥(균형)"]
assert six["metricsPlus"].endswith(
"성토 900㎥ · 남는 흙 +122㎥(남음) · 기준 초과 2구간 · 이격 3.3m"
)
assert "남는 흙 −578㎥(모자람)" in six["metricsMinus"]
# 서버가 안 주면 칸이 없다(옛 답) — 기존 비교값 글 그대로
assert "남는 흙" not in six["metricsNone"]
def test_같은_노선_알림_줄(results):
six = results["sixth"]
assert six["similarTwo"] == (
"이 지형은 제약 안에서 갈 길이 폭 13.5m 한 띠뿐 — "
"토공 최소 · 기울기 순한과 거의 같은 노선(가장 먼 거리 6.1m)"
)
assert six["similarOne"] == (
"이 지형은 제약 안에서 갈 길이 폭 13.5m 한 띠뿐 — 흙 균형과 거의 같은 노선(가장 먼 거리 6.1m)"
)
# 띠 폭이 없어도 줄은 선다 · 받침 없는 이름은 「와」
assert "폭" not in six["similarNoBand"] and "비중 계산과 거의" in six["similarNoBand"]
# 없으면 줄 숨김 — 빈 목록 · 칸 없음 · 선택 없음
assert six["similarEmpty"] == "" and six["similarMissing"] == "" and six["similarNull"] == ""
assert six["similarUnknown"] == [] # 이름 · 거리를 모르는 항목은 버림
assert six["bandParsed"] == 13.5
assert "제약 값을 바꾸면" in six["hint"]
def test_비중_계산_넘은_구간_사유_그대로(results):
six = results["sixth"]
assert six["weightedViolation"] == "제약 못 지킨 구간 1곳: No.9+0.0 29.1%(비중 계산)"
assert six["weightedViolationReason"] == "No.9+0.0 29.1%(비중 계산)"
@@ -43,6 +43,8 @@ LOGICS: list[tuple[str, dict[str, float] | None]] = [
("follow", None),
("earthwork", None),
("gentle", None),
("short", None),
("balance", None),
("weighted", {"expected": 50, "earthwork": 25, "grade": 25}),
]