feat(B05): 노선 추천 정리 — 알약 다섯 한 줄 · 종단은 갈래가 정함 · 오른쪽 위 [적용]/[닫기] · 지도 안 옵션 겹창 · 추천 토량에 곡선부 확폭(77)

- 알약 = 예상노선 추적(종단 없음) · 영선 · 토공 최소 · 기울기 최소 · 비중 계산(넷은 종단 늘 반영) · 안전 우선 · 계류 횡단 최소 · 공사비 어림 최소 화면에서 뺌
- 「종단 반영」 켬/끔 단추 없앰(추천 창 · 종단 띠) — 띠 · [확인]은 지금 추천 갈래로 종단 여부
- ✕ 없애고 [적용] · [닫기]를 제목줄 오른쪽 위로 · 옵션 칸은 지도 왼쪽 위 작은 겹창 · 창 폭 넓힘
- 적용한 추천 갈래가 있으면 창을 다시 열 때 그 갈래가 골라진 채
- 도움말: 종단 반영 = 측점마다 횡단을 보고 정한 종단(적분값 아님)
- 균형 종단 단면적에 곡선부 확폭(B05 측점 생성과 같은 셈) — 빼서 확정 뒤 성토가 1.7배 벌어졌음(노선 267 같은 계획고: 성 10,102 → 17,819 · 본 설계 16,140)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019EMfGERKUg5nMMNQsyVZ2j
This commit is contained in:
2026-10-07 21:29:25 +09:00
co-authored by Claude Opus 5.5
parent fd1fc8b401
commit 6aa77e1bb7
12 changed files with 282 additions and 237 deletions
@@ -228,8 +228,20 @@ const initialRoute = {
const mapLines = {
fit: model.mapPaths(
[
{ kind: "expected", points: [[0, 0], [100, 0]] },
{ kind: "preview", points: [[0, 0], [100, 50]] },
{
kind: "expected",
points: [
[0, 0],
[100, 0],
],
},
{
kind: "preview",
points: [
[0, 0],
[100, 50],
],
},
],
460,
340,
@@ -241,7 +253,10 @@ const mapLines = {
// ⑦ 되돌리기 · 다시하기가 걸음마다 실은 「현재 추천」을 되살린다(노선과 짝).
const history = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_History.ts"));
const step = (x, rec) => ({
planned: [[x, 0], [x + 1, 1]],
planned: [
[x, 0],
[x + 1, 1],
],
curveOn: [true, true],
curveRadius: [null, null],
curveLock: [null, null],
@@ -257,7 +272,12 @@ const historyRecs = [trail.undo(), trail.undo(), trail.redo(), trail.redo()].map
// ⑧ 비중 고정(계획서 26-2c) — 고정한 값은 안 움직이고 나머지가 합 100 을 맞춘다.
const W = { expected: 70, earthwork: 15, grade: 15 };
const L = (...keys) => ({ expected: false, earthwork: false, grade: false, ...Object.fromEntries(keys.map((k) => [k, true])) });
const L = (...keys) => ({
expected: false,
earthwork: false,
grade: false,
...Object.fromEntries(keys.map((k) => [k, true])),
});
const sweep = [];
for (const locks of [L(), L("expected"), L("earthwork"), L("grade"), L("expected", "grade")]) {
for (const key of ["expected", "earthwork", "grade"]) {
@@ -266,7 +286,9 @@ for (const locks of [L(), L("expected"), L("earthwork"), L("grade"), L("expected
sweep.push({
sum: out.expected + out.earthwork + out.grade,
lockedSame: Object.keys(locks).every((k) => !locks[k] || out[k] === W[k]),
range: [out.expected, out.earthwork, out.grade].every((n) => Number.isInteger(n) && n >= 0 && n <= 100),
range: [out.expected, out.earthwork, out.grade].every(
(n) => Number.isInteger(n) && n >= 0 && n <= 100,
),
});
}
}
@@ -275,9 +297,18 @@ const weightLocks = {
freeThree: model.moveWeight(W, L(), "expected", 40),
lockExpected: model.moveWeight(W, L("expected"), "earthwork", 20),
lockExpectedClamp: model.moveWeight(W, L("expected"), "earthwork", 90),
twoLocked: model.moveWeight({ expected: 70, earthwork: 20, grade: 10 }, L("expected", "earthwork"), "grade", 50),
twoLocked: model.moveWeight(
{ expected: 70, earthwork: 20, grade: 10 },
L("expected", "earthwork"),
"grade",
50,
),
movingLocked: model.moveWeight(W, L("expected"), "expected", 10),
derived: [model.derivedWeightKey(L()), model.derivedWeightKey(L("expected")), model.derivedWeightKey(L("expected", "earthwork"))],
derived: [
model.derivedWeightKey(L()),
model.derivedWeightKey(L("expected")),
model.derivedWeightKey(L("expected", "earthwork")),
],
canLock: {
none: model.canLockWeight(L(), "grade"),
one: model.canLockWeight(L("expected"), "grade"),
@@ -293,8 +324,18 @@ const weightLocks = {
over: model.inputWeight(W, L(), "earthwork", 101),
nan: model.inputWeight(W, L(), "earthwork", Number.NaN),
onLocked: model.inputWeight(W, L("earthwork"), "earthwork", 15),
derivedSame: model.inputWeight({ expected: 70, earthwork: 20, grade: 10 }, L("expected", "earthwork"), "grade", 10),
derivedOther: model.inputWeight({ expected: 70, earthwork: 20, grade: 10 }, L("expected", "earthwork"), "grade", 30),
derivedSame: model.inputWeight(
{ expected: 70, earthwork: 20, grade: 10 },
L("expected", "earthwork"),
"grade",
10,
),
derivedOther: model.inputWeight(
{ expected: 70, earthwork: 20, grade: 10 },
L("expected", "earthwork"),
"grade",
30,
),
},
};
@@ -321,10 +362,9 @@ const gradeline = withExtras(
);
const sixth = {
labels: Object.fromEntries(
["follow", "gradeline", "earthwork", "gentle", "safe", "crossings", "cost", "weighted"].map((k) => [
k,
model.LOGIC_LABEL[k],
]),
["follow", "gradeline", "earthwork", "gentle", "safe", "crossings", "cost", "weighted"].map(
(k) => [k, model.LOGIC_LABEL[k]],
),
),
removedLogics: ["short", "balance"].map((k) => k in model.LOGIC_LABEL),
// 옛 저장값(short · balance)은 예상노선 추적으로 읽는다 — 서버 기억 · 캐시 둘 다.
@@ -337,8 +377,9 @@ const sixth = {
criteriaTarget: model.criteriaLine(gradeline.criteria, 0, true),
criteriaNoTarget: model.criteriaLine(gradeline.criteria, 0, false),
targetParsed: gradeline.criteria.targetGradePct,
targetOverride: model.normalizeRouteInitial(plainResponse, "gradeline", null, { target_grade_pct: 10 })
.criteriaOverrides,
targetOverride: model.normalizeRouteInitial(plainResponse, "gradeline", null, {
target_grade_pct: 10,
}).criteriaOverrides,
targetBase: model.serverBase({ logic: "gradeline", criteria: { target_grade_pct: 10 } }),
sameTarget: [
model.sameBase(
@@ -360,9 +401,21 @@ const sixth = {
}),
),
similarOne: model.similarLine(
withExtras({ band_width_m: 13.54, similar: [{ logic: "safe", max_gap_m: 6.1 }] }),
withExtras({ band_width_m: 13.54, similar: [{ logic: "gradeline", max_gap_m: 6.1 }] }),
),
similarNoBand: model.similarLine(
withExtras({ similar: [{ logic: "weighted", max_gap_m: 9.1 }] }),
),
// 화면에서 뺀 갈래(안전 우선 · 계류 횡단 최소 · 공사비 어림 최소)만 있으면 줄 숨김(PLAN 77-1)
similarHidden: model.similarLine(
withExtras({
band_width_m: 13.5,
similar: [
{ logic: "safe", max_gap_m: 1 },
{ logic: "cost", max_gap_m: 2 },
],
}),
),
similarNoBand: model.similarLine(withExtras({ similar: [{ logic: "cost", max_gap_m: 9.1 }] })),
similarEmpty: model.similarLine(withExtras({ band_width_m: 13.5, similar: [] })),
similarMissing: model.similarLine(withExtras({})),
similarNull: model.similarLine(null),
@@ -464,7 +517,16 @@ const balance63 = {
session._map.clear();
session.setItem(
"aislo:result:routeedit-initial:p-9",
JSON.stringify({ logic: "short", weights: null, snapshot: { planned: [[0, 0], [1, 1]] } }),
JSON.stringify({
logic: "short",
weights: null,
snapshot: {
planned: [
[0, 0],
[1, 1],
],
},
}),
);
sixth.legacyCache = model.readInitialChoice("p-9");
@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
"""노선 추천 균형 종단에 곡선부 확폭 — PLAN 77-7.
빼면 확정 뒤 본 설계(확폭 있음)와 성토가 1.7배로 벌어졌다(랩탑 메인 노선 267 실측).
추천 측점 확폭 = B05 측점 생성과 같은 `_plan_radii` · `_curve_widenings`.
"""
import numpy as np
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import _widenings
from config.config_system_design import curve_widening_m
def _route() -> tuple[np.ndarray, np.ndarray, np.ndarray]:
straight = [(x, 0.0) for x in np.arange(0.0, 60.0, 2.0)]
radius = 15.0
arc = [
(60.0 + radius * np.sin(t), radius - radius * np.cos(t))
for t in np.linspace(0.0, np.pi / 2, 30)
]
xy = np.asarray(straight + arc + [(75.0, 15.0 + d) for d in np.arange(2.0, 60.0, 2.0)])
chain = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(xy, axis=0).T))])
at = np.append(np.arange(0.0, chain[-1], 5.0), chain[-1])
return xy, chain, at
def test_곡선_측점은_확폭_직선은_없음():
xy, chain, at = _route()
rows = _widenings(xy, chain, at)
assert len(rows) == len(at)
on_curve = [row for a, row in zip(at, rows) if 64.0 < a < 80.0]
assert on_curve and all(row["curve_widening_m"] > 0 for row in on_curve)
assert max(row["curve_widening_m"] for row in on_curve) == curve_widening_m(15.0)
assert all(row["curve_outer_side"] in ("left", "right") for row in on_curve)
straight = [row for a, row in zip(at, rows) if a < 40.0]
assert all(row["curve_widening_m"] == 0 for row in straight)
+12 -18
View File
@@ -249,13 +249,13 @@ def test_비중_값_칸_직접_입력(results):
}
def test_추천_갈래_여덟_이름_알약_순서(results):
def test_추천_갈래_이름_알약_다섯_순서(results):
six = results["sixth"]
assert six["labels"] == {
"follow": "예상노선 추적",
"gradeline": "영선 노선",
"earthwork": "토공 최소",
"gentle": "기울기 순한",
"gentle": "기울기 최소",
"safe": "안전 우선",
"crossings": "계류 횡단 최소",
"cost": "공사비 어림 최소",
@@ -267,24 +267,17 @@ def test_추천_갈래_여덟_이름_알약_순서(results):
with open(page, encoding="utf-8") as handle:
text = handle.read()
start = text.index("const PILLS")
pills = text[start : text.index("];", start)]
pills = text[start : text.index("] as const", start)]
order = [
pills.index(f'"{name}"')
for name in (
"follow",
"gradeline",
"earthwork",
"gentle",
"safe",
"crossings",
"cost",
"weighted",
)
for name in ("follow", "gradeline", "earthwork", "gentle", "weighted")
]
assert order == sorted(order) # 알약 순서
assert order == sorted(order) # 알약 순서 · 다섯(PLAN 77-1)
for gone in ("safe", "crossings", "cost"):
assert f'"{gone}"' not in pills
assert '"short"' not in pills and '"balance"' not in pills
assert (
"grid-template-columns: repeat(4"
"grid-template-columns: repeat(5"
in open(
os.path.join(
HERE, "..", "..", "B05_Profile", "B05_Profile_UI_Style_RouteRecommend.css"
@@ -337,13 +330,14 @@ def test_같은_노선_알림_줄(results):
six = results["sixth"]
assert six["similarTwo"] == (
"이 지형은 제약 안에서 갈 길이 폭 13.5m 한 띠뿐 — "
"토공 최소 · 기울기 순한과 거의 같은 노선(가장 먼 거리 6.1m)"
"토공 최소 · 기울기 최소와 거의 같은 노선(가장 먼 거리 6.1m)"
)
assert six["similarOne"] == (
"이 지형은 제약 안에서 갈 길이 폭 13.5m 한 띠뿐 — 안전 우선과 거의 같은 노선(가장 먼 거리 6.1m)"
"이 지형은 제약 안에서 갈 길이 폭 13.5m 한 띠뿐 — 영선 노선과 거의 같은 노선(가장 먼 거리 6.1m)"
)
assert six["similarHidden"] == "" # 화면에서 뺀 갈래만이면 줄 숨김(PLAN 77-1)
# 띠 폭이 없어도 줄은 선다 · 받침 있는 이름은 「과」
assert "폭" not in six["similarNoBand"] and "공사비 어림 최소와 거의" in six["similarNoBand"]
assert "폭" not in six["similarNoBand"] and "비중 계산과 거의" in six["similarNoBand"]
# 없으면 줄 숨김 — 빈 목록 · 칸 없음 · 선택 없음
assert six["similarEmpty"] == "" and six["similarMissing"] == "" and six["similarNull"] == ""
assert six["similarUnknown"] == [] # 이름 · 거리를 모르는 항목(옛 short 포함)은 버림