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