feat(B05): 초기노선 갈래 — 제약 못 지킨 구간 한 줄 · 상한 풂 안내 · 기울기 잴 지반 고름 칸

- 비교값 줄 아래 「제약 못 지킨 구간 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sCeZz8z8YnbovsKgn7HK7
This commit is contained in:
2026-09-29 10:42:14 +09:00
co-authored by Claude Opus 5.5
parent 258a3512a4
commit 1758ba0fb2
7 changed files with 177 additions and 2 deletions
@@ -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 {
</div>
<div class="b05-routeedit__initial-now"></div>
<div class="b05-routeedit__initial-line" data-line="metrics"></div>
<div class="b05-routeedit__initial-line" data-line="violations" hidden></div>
<div class="b05-routeedit__initial-line is-warn" data-line="relaxed" hidden></div>
<div class="b05-routeedit__initial-line" data-line="criteria"></div>`;
overlay.querySelector(".b05-routeedit__canvas-wrap")!.append(box);
const nowLine = box.querySelector<HTMLElement>(".b05-routeedit__initial-now")!;
const metricsEl = box.querySelector<HTMLElement>('[data-line="metrics"]')!;
const criteriaEl = box.querySelector<HTMLElement>('[data-line="criteria"]')!;
const violationsEl = box.querySelector<HTMLElement>('[data-line="violations"]')!;
const relaxedEl = box.querySelector<HTMLElement>('[data-line="relaxed"]')!;
const weightsBox = box.querySelector<HTMLElement>(".b05-routeedit__weights")!;
const openWeights = box.querySelector<HTMLButtonElement>('[data-act="weights-open"]')!;
const resetButton = overlay.querySelector<HTMLButtonElement>('[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<HTMLInputElement>(`[data-weight="${key}"]`)!.value = String(weights[key]);
@@ -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<Record<string, unknown>>).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<InitialChoice>("routeedit-initial", projectId);
@@ -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;
@@ -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: [
@@ -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 ?? []);
+19 -2
View File
@@ -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}
@@ -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"]: