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:
@@ -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(" · ");
|
||||
|
||||
Reference in New Issue
Block a user