diff --git a/B05_Profile/B05_Profile_Api_RouteInitial.ts b/B05_Profile/B05_Profile_Api_RouteInitial.ts index b846d9e9..4a658d9c 100644 --- a/B05_Profile/B05_Profile_Api_RouteInitial.ts +++ b/B05_Profile/B05_Profile_Api_RouteInitial.ts @@ -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 { diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts index 4b5ea6c6..d0578760 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts @@ -19,6 +19,8 @@ export const LOGIC_LABEL: Record = { 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>) { + 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(" · "); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts index f86fe93d..e78d004b 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts @@ -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 = { follow: "전처리가 만든 기본 노선 — 예상노선을 따라갑니다", earthwork: "절토 · 성토가 가장 적은 노선", gentle: "종단기울기가 가장 순한 노선", + short: "연장이 가장 짧은 노선(제약 지킴)", + balance: "절토와 성토가 비슷해 남는 흙이 가장 적은 노선", weighted: "예상 · 토공 · 기울기 비중(합 100)으로 셈한 노선", }; @@ -196,6 +207,8 @@ export function openRecommendModal(params: RecommendParams): void {
+ +
@@ -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) : ""; diff --git a/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css b/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css index 8d00da23..60b2de8d 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css @@ -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); diff --git a/resources/tester/helper_b05_routeedit_initial.cjs b/resources/tester/helper_b05_routeedit_initial.cjs index 16573519..bada657b 100644 --- a/resources/tester/helper_b05_routeedit_initial.cjs +++ b/resources/tester/helper_b05_routeedit_initial.cjs @@ -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, }), ); diff --git a/resources/tester/test_b05_routeedit_initial.py b/resources/tester/test_b05_routeedit_initial.py index be5cb5e7..ece139ab 100644 --- a/resources/tester/test_b05_routeedit_initial.py +++ b/resources/tester/test_b05_routeedit_initial.py @@ -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%(비중 계산)" diff --git a/resources/tester/test_b05_routeedit_initial_roundtrip.py b/resources/tester/test_b05_routeedit_initial_roundtrip.py index a3e9d833..14d7d9a0 100644 --- a/resources/tester/test_b05_routeedit_initial_roundtrip.py +++ b/resources/tester/test_b05_routeedit_initial_roundtrip.py @@ -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}), ]