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:
@@ -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}),
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user