From 1758ba0fb2e633404cec3ddc3aa41bfd85be2674 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 10:42:14 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=EC=B4=88=EA=B8=B0=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=EA=B0=88=EB=9E=98=20=E2=80=94=20=EC=A0=9C=EC=95=BD?= =?UTF-8?q?=20=EB=AA=BB=20=EC=A7=80=ED=82=A8=20=EA=B5=AC=EA=B0=84=20?= =?UTF-8?q?=ED=95=9C=20=EC=A4=84=20=C2=B7=20=EC=83=81=ED=95=9C=20=ED=92=82?= =?UTF-8?q?=20=EC=95=88=EB=82=B4=20=C2=B7=20=EA=B8=B0=EC=9A=B8=EA=B8=B0=20?= =?UTF-8?q?=EC=9E=B4=20=EC=A7=80=EB=B0=98=20=EA=B3=A0=EB=A6=84=20=EC=B9=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 비교값 줄 아래 「제약 못 지킨 구간 N곳: No.9 16.4%(지형상 불가능) · …」(없으면 「모두 지킴」 · 5곳 넘으면 「외 N곳」 · 풍선에 전부와 사유) - relaxed 면 「지형상 14.0% 로 못 이어져 상한을 29.1% 로 풂」(경고 색) - 공통 제약 줄에 서버 grade_smooth_m 을 「기울기 잴 지반 5m 고름」으로 - 시험 — 글 만들기(test_b05_routeedit_initial) · 사본 왕복에서 목록 · 풂 · 고름 칸이 화면 줄에(test_b05_routeedit_initial_roundtrip) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015sCeZz8z8YnbovsKgn7HK7 --- .../B05_Profile_UI_RouteEdit_Initial.ts | 15 +++++ .../B05_Profile_UI_RouteEdit_Initial_Model.ts | 64 +++++++++++++++++++ .../B05_Profile_UI_Style_RouteEdit.css | 6 ++ .../tester/helper_b05_routeedit_initial.cjs | 48 ++++++++++++++ .../helper_b05_routeedit_initial_apply.cjs | 3 + .../tester/test_b05_routeedit_initial.py | 21 +++++- .../test_b05_routeedit_initial_roundtrip.py | 22 +++++++ 7 files changed, 177 insertions(+), 2 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts index 96605545..7affdc8a 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts @@ -23,9 +23,12 @@ import { criteriaLine, DEFAULT_WEIGHTS, metricsLine, + violationsLine, + violationText, normalizeRouteInitial, readInitialChoice, rebalanceWeights, + relaxedLine, sameBase, serverBase, WEIGHT_KEYS, @@ -90,12 +93,16 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
+ +
`; overlay.querySelector(".b05-routeedit__canvas-wrap")!.append(box); const nowLine = box.querySelector(".b05-routeedit__initial-now")!; const metricsEl = box.querySelector('[data-line="metrics"]')!; const criteriaEl = box.querySelector('[data-line="criteria"]')!; + const violationsEl = box.querySelector('[data-line="violations"]')!; + const relaxedEl = box.querySelector('[data-line="relaxed"]')!; const weightsBox = box.querySelector(".b05-routeedit__weights")!; const openWeights = box.querySelector('[data-act="weights-open"]')!; const resetButton = overlay.querySelector('[data-act="reset"]')!; @@ -120,6 +127,14 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { ? "비교값 — 서버 답에 없습니다." : metricsLine(choice?.metrics ?? null); criteriaEl.textContent = criteriaLine(choice?.criteria ?? null, params.planMinRadiusM()); + // 제약 못 지킨 구간 · 상한 풂 — 서버가 준 목록 그대로(계획서 20). 없는 칸이면 숨긴다. + violationsEl.textContent = violationsLine(choice?.metrics ?? null); + violationsEl.title = (choice?.metrics?.violations ?? []) + .map((item) => `${violationText(item)} — ${item.reason}`) + .join("\n"); + violationsEl.hidden = !violationsEl.textContent; + relaxedEl.textContent = relaxedLine(choice); + relaxedEl.hidden = !relaxedEl.textContent; box.dataset.logic = logic; for (const key of WEIGHT_KEYS) { box.querySelector(`[data-weight="${key}"]`)!.value = String(weights[key]); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts index 0a8635e0..8ab9f585 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts @@ -47,6 +47,17 @@ export interface InitialMetrics { fillM3: number | null; overLimitCount: number | null; offsetM: number | null; + /** 제약 못 지킨 구간 — 서버 `metrics.violations`. 칸이 없으면 null(옛 답). */ + violations: InitialViolation[] | null; +} + +/** 제약 못 지킨 구간 하나 — 종단기울기 · 곡선반지름 · 계류 이격. */ +export interface InitialViolation { + kind: string; + station: string; + value: number | null; + limit: number | null; + reason: string; } /** 공통 제약 기준값 — 서버가 준 값. `pending` 이 남아 `confirmed` 가 거짓이면 지식DB 후보. */ @@ -54,6 +65,8 @@ export interface InitialCriteria { maxGradePct: number | null; minRadiusM: number | null; streamOffsetM: number | null; + /** 기울기를 잴 지반을 고른 폭(m) — 서버 `grade_smooth_m`. */ + gradeSmoothM: number | null; confirmed: boolean; } @@ -64,6 +77,8 @@ export interface InitialChoice { snapshot: RouteEditSnapshot; metrics: InitialMetrics | null; criteria: InitialCriteria | null; + /** 제약을 다 지키는 길이 없어 기울기 상한을 풀었나 — 풀었으면 그 값(%). */ + terrainBoundPct?: number | null; } const num = (value: unknown): number | null => @@ -120,13 +135,24 @@ export function normalizeRouteInitial( fillM3: pick(m, "fill_m3"), overLimitCount: pick(over, "total"), offsetM: pick(m, "offset_avg_m"), + violations: Array.isArray(m.violations) + ? (m.violations as Array>).map((item) => ({ + kind: String(item.kind ?? ""), + station: String(item.station ?? ""), + value: num(item.value), + limit: num(item.limit), + reason: String(item.reason ?? ""), + })) + : null, } : null, + terrainBoundPct: body.relaxed === true ? num(body.terrain_bound_pct) : null, criteria: body.criteria ? { maxGradePct: pick(c, "max_grade_pct"), minRadiusM: pick(c, "min_radius_m"), streamOffsetM: pick(c, "stream_offset_m"), + gradeSmoothM: pick(c, "grade_smooth_m"), // 확정 대기 칸 이름 목록이 비어야 확정이다. confirmed: Array.isArray(c.pending) && c.pending.length === 0, } @@ -197,10 +223,48 @@ export function criteriaLine(criteria: InitialCriteria | null, planMinRadiusM: n `최소 R ${fixed(radius, 0, "m")}`, `계류 이격 ≥ ${fixed(criteria?.streamOffsetM ?? null, 1, "m")}`, ]; + // 기울기는 고른 지반으로 잰다 — 그 폭을 서버가 주면 그대로 보인다. + if (criteria?.gradeSmoothM) + parts.push(`기울기 잴 지반 ${fixed(criteria.gradeSmoothM, 0, "m")} 고름`); const tail = criteria?.confirmed ? "" : " (후보 · 사용자 확정 대기)"; return `공통 제약 ${parts.join(" · ")}${tail}`; } +/** 목록 한 줄에 적을 구간 수 — 넘으면 「외 N곳」(전부는 글상자 풍선에). */ +const VIOLATION_SHOWN = 5; + +/** 구간 하나 — 「No.9 29.1%(지형상 불가능)」. 사유는 「 — 」 앞 머리만. */ +export function violationText(item: InitialViolation): string { + const value = + item.kind === "grade" + ? fixed(item.value, 1, "%") + : item.kind === "radius" + ? `R ${fixed(item.value, 1, "m")}` + : item.kind === "stream" + ? `계류 ${fixed(item.value, 1, "m")}` + : fixed(item.value, 1, ""); + const head = item.reason.split(" — ")[0].trim(); + return `${item.station} ${value}${head ? `(${head})` : ""}`; +} + +/** 제약 못 지킨 구간 한 줄 — 없으면 「모두 지킴」. 서버가 목록을 안 줬으면 빈 글. */ +export function violationsLine(metrics: InitialMetrics | null): string { + const items = metrics?.violations; + if (!items) return ""; + if (!items.length) return "제약 못 지킨 구간 없음 — 모두 지킴"; + const shown = items.slice(0, VIOLATION_SHOWN).map(violationText).join(" · "); + const more = items.length > VIOLATION_SHOWN ? ` 외 ${items.length - VIOLATION_SHOWN}곳` : ""; + return `제약 못 지킨 구간 ${items.length}곳: ${shown}${more}`; +} + +/** 상한을 풀었을 때만 — 「지형상 14% 로 못 이어져 상한을 23.5% 로 풂」. 아니면 빈 글. */ +export function relaxedLine(choice: InitialChoice | null): string { + const bound = choice?.terrainBoundPct; + if (bound === null || bound === undefined) return ""; + const limit = fixed(choice?.criteria?.maxGradePct ?? null, 1, "%"); + return `지형상 ${limit} 로 못 이어져 상한을 ${fixed(bound, 1, "%")} 로 풂`; +} + /** 캐시에서 마지막 초기값을 읽는다. 없거나 꼴이 틀리면 null(= 예상노선 추종). */ export function readInitialChoice(projectId: string): InitialChoice | null { const saved = readState("routeedit-initial", projectId); diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index 33b79f1c..4b17d745 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -197,6 +197,12 @@ line-height: 1.45; } +/* 상한을 풀었다는 안내 — 눈에 띄게(계획서 20). */ +.b05-routeedit__initial-line.is-warn { + color: var(--color-warning); + font-weight: 600; +} + .b05-routeedit__legend { display: inline-flex; align-items: center; diff --git a/resources/tester/helper_b05_routeedit_initial.cjs b/resources/tester/helper_b05_routeedit_initial.cjs index d45ec375..2adca828 100644 --- a/resources/tester/helper_b05_routeedit_initial.cjs +++ b/resources/tester/helper_b05_routeedit_initial.cjs @@ -79,6 +79,36 @@ const plain = model.normalizeRouteInitial( over_limit: { grade: 1, radius: 0, stream: 1, total: 2 }, offset_avg_m: 3.25, offset_max_m: 9.8, + raw_max_grade_pct: 41.2, + violations: [ + { + kind: "grade", + from_m: 180, + to_m: 220, + station: "No.9+0.0", + value: 29.13, + limit: 14, + reason: "지형상 불가능 — 복도 안 어떤 길도 23.5% 이상 기울기를 지나야 이어짐", + }, + { + kind: "radius", + from_m: 400, + to_m: 412, + station: "No.20+0.0", + value: 9.5, + limit: 12, + reason: "곡선 둘 자리 모자람 — 앞뒤 직선이 짧아 최소 반지름 원호가 안 들어감", + }, + { + kind: "stream", + from_m: 600, + to_m: 700, + station: "No.30+0.0", + value: 32, + limit: 100, + reason: "계류 버퍼 안 — 건너기 · 기점 · 종점 봐줌을 넘음", + }, + ], }, criteria: { max_grade_pct: 14, @@ -86,8 +116,11 @@ const plain = model.normalizeRouteInitial( stream_offset_m: 5, road_width_m: 4, corridor_m: 100, + grade_smooth_m: 5, pending: ["max_grade_pct", "stream_offset_m"], }, + relaxed: true, + terrain_bound_pct: 23.46, }, "earthwork", null, @@ -132,6 +165,21 @@ const labels = { criteria: model.criteriaLine(plain.criteria, 0), criteriaConfirmed: model.criteriaLine({ ...plain.criteria, confirmed: true }, 0), criteriaFallback: model.criteriaLine(null, 15), + violations: model.violationsLine(plain.metrics), + violationsNone: model.violationsLine({ ...plain.metrics, violations: [] }), + violationsOld: model.violationsLine({ ...plain.metrics, violations: null }), + violationsMany: model.violationsLine({ + ...plain.metrics, + violations: Array.from({ length: 7 }, (_, i) => ({ + kind: "grade", + station: `No.${i}+0.0`, + value: 15 + i, + limit: 14, + reason: "곡선 맞춤 뒤 지반 차 — 측점 사이 지반이 상한을 넘음", + })), + }), + relaxed: model.relaxedLine(plain), + notRelaxed: model.relaxedLine(planShape), pendingEmpty: model.normalizeRouteInitial( { nodes: [ diff --git a/resources/tester/helper_b05_routeedit_initial_apply.cjs b/resources/tester/helper_b05_routeedit_initial_apply.cjs index b267032d..dfc77ef8 100644 --- a/resources/tester/helper_b05_routeedit_initial_apply.cjs +++ b/resources/tester/helper_b05_routeedit_initial_apply.cjs @@ -72,6 +72,9 @@ const out = jobs.map((job) => { }, label: model.choiceLabel(choice.logic, choice.weights), metrics: model.metricsLine(choice.metrics), + violations: model.violationsLine(choice.metrics), + relaxed: model.relaxedLine(choice), + criteria: model.criteriaLine(choice.criteria, 0), }; } const flat = edits.flattenServerPlan(job.plan.nodes ?? [], job.plan.curves ?? []); diff --git a/resources/tester/test_b05_routeedit_initial.py b/resources/tester/test_b05_routeedit_initial.py index 5b44155d..59160ce2 100644 --- a/resources/tester/test_b05_routeedit_initial.py +++ b/resources/tester/test_b05_routeedit_initial.py @@ -81,15 +81,32 @@ def test_비교값과_기준값_글(results): ) assert "로직을 누르면" in labels["metricsNone"] assert labels["criteria"] == ( - "공통 제약 종단기울기 ≤ 14.0% · 최소 R 12m · 계류 이격 ≥ 5.0m (후보 · 사용자 확정 대기)" + "공통 제약 종단기울기 ≤ 14.0% · 최소 R 12m · 계류 이격 ≥ 5.0m · 기울기 잴 지반 5m 고름" + " (후보 · 사용자 확정 대기)" ) - assert labels["criteriaConfirmed"].endswith("5.0m") + assert labels["criteriaConfirmed"].endswith("지반 5m 고름") assert labels["pendingEmpty"] is True assert labels["criteriaFallback"] == ( "공통 제약 종단기울기 ≤ — · 최소 R 15m · 계류 이격 ≥ — (후보 · 사용자 확정 대기)" ) +def test_제약_못_지킨_구간과_상한_풂(results): + labels = results["labels"] + assert labels["violations"] == ( + "제약 못 지킨 구간 3곳: No.9+0.0 29.1%(지형상 불가능)" + " · No.20+0.0 R 9.5m(곡선 둘 자리 모자람) · No.30+0.0 계류 32.0m(계류 버퍼 안)" + ) + assert labels["violationsNone"] == "제약 못 지킨 구간 없음 — 모두 지킴" + assert labels["violationsOld"] == "" # 옛 답(목록 칸 없음)은 줄을 숨긴다 + assert labels["violationsMany"].startswith( + "제약 못 지킨 구간 7곳: No.0+0.0 15.0%(곡선 맞춤 뒤 지반 차)" + ) + assert labels["violationsMany"].endswith(" 외 2곳") + assert labels["relaxed"] == "지형상 14.0% 로 못 이어져 상한을 23.5% 로 풂" + assert labels["notRelaxed"] == "" + + def test_서버가_적어_둔_현재_초기값(results): bases = results["bases"] assert bases["follow"] == {"logic": "follow", "weights": None} diff --git a/resources/tester/test_b05_routeedit_initial_roundtrip.py b/resources/tester/test_b05_routeedit_initial_roundtrip.py index 195423b6..00494ff1 100644 --- a/resources/tester/test_b05_routeedit_initial_roundtrip.py +++ b/resources/tester/test_b05_routeedit_initial_roundtrip.py @@ -192,6 +192,28 @@ def test_비교값이_응답에_다_있음(rounds): assert "—" not in item["applied"]["metrics"], item["applied"]["metrics"] +def test_제약_못_지킨_구간이_목록과_화면_줄에(rounds): + for item in rounds["rounds"]: + answer, applied = item["answer"], item["applied"] + violations = answer["metrics"]["violations"] + assert answer["metrics"]["over_limit"]["total"] == len(violations) + for entry in violations: + assert entry["kind"] in ("grade", "radius", "stream") + assert entry["station"].startswith("No.") and entry["reason"] + assert isinstance(entry["value"], (int, float)) + head = ( + f"제약 못 지킨 구간 {len(violations)}곳: " if violations else "제약 못 지킨 구간 없음" + ) + assert applied["violations"].startswith(head), applied["violations"] + if answer.get("relaxed"): + bound = f"{answer['terrain_bound_pct']:.1f}%" + assert applied["relaxed"].endswith(f"상한을 {bound} 로 풂"), applied["relaxed"] + else: + assert applied["relaxed"] == "" + smooth = answer["criteria"]["grade_smooth_m"] + assert f"기울기 잴 지반 {smooth:.0f}m 고름" in applied["criteria"] + + def test_확인하면_서버가_초기값을_적고_다시_열면_그대로(rounds, api): assert len(api.recomputed) == len(LOGICS) for item in rounds["rounds"]: