Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1

This commit is contained in:
2026-10-18 13:14:56 +09:00
7 changed files with 247 additions and 16 deletions
@@ -160,7 +160,7 @@ export interface InitialMetrics {
violations: InitialViolation[] | null;
}
/** 제약 못 지킨 구간 하나 — 종단기울기 · 곡선반지름 · 계류 이격. */
/** 서버 목록 한 줄 — 곡선반지름 · 계류 이격 · 측점 밖(못 지킴) · 원지반 기울기(`grade` · 참고만 · 419). */
export interface InitialViolation {
kind: string;
station: string;
@@ -205,6 +205,9 @@ export interface InitialChoice {
offStationCurves?: number | null;
/** 추천 창 「종단 반영」 값(421) — [적용] 균형 종단 · 다시 열 때 단추. 옛 캐시면 없음. */
withProfile?: boolean;
/** 시종점 높이 차 ÷ 연장(%) · 상한을 넘나(종단으로 못 풂) — 서버 `ends_grade_pct` · `ends_over_limit`(419). */
endsGradePct?: number | null;
endsOverLimit?: boolean;
}
/** 거의 같은 노선인 다른 갈래 하나 — 두 노선 사이 가장 먼 거리(m). */
@@ -328,7 +331,12 @@ export function normalizeRouteInitial(
gradeDevPct: pick(m, "grade_dev_pct"),
costIndex: pick(m, "cost_index"),
surplusM3: pick(m, "surplus_m3"),
overLimitCount: pick(over, "total"),
// 「못 지킨 구간」과 같은 목록에서 셈(원지반 기울기 뺌 · 421-2) — 목록 없는 옛 답만 서버 수
overLimitCount: Array.isArray(m.violations)
? (m.violations as Array<Record<string, unknown>>).filter(
(item) => item.kind !== "grade",
).length
: pick(over, "total"),
offsetM: pick(m, "offset_avg_m"),
violations: Array.isArray(m.violations)
? (m.violations as Array<Record<string, unknown>>).map((item) => ({
@@ -346,6 +354,8 @@ export function normalizeRouteInitial(
bandWidthM: num(body.band_width_m),
stored: body.stored === true,
offStationCurves: num(body.off_station_curves),
endsGradePct: num(body.ends_grade_pct),
endsOverLimit: body.ends_over_limit === true,
// 서버가 셈에 쓴 값이 먼저(420) · 옛 서버면 보낸 값
withProfile: typeof body.with_profile === "boolean" ? body.with_profile : withProfile,
balance: parseBalance(m.profile),
@@ -555,9 +565,17 @@ export function violationText(item: InitialViolation): string {
return `${item.station} ${value}${head ? `(${head})` : ""}`;
}
/** 원지반 기울기 — 참고만(419 · 계획선 높이로 푸는 값) · 「못 지킨 구간」 · 「기준 초과」에 안 듦. */
const isGroundGrade = (item: InitialViolation): boolean => item.kind === "grade";
/** 제약 못 지킨 구간(R · 계류 · 측점) — 서버 목록에서 원지반 기울기를 뺀 것. 옛 답(목록 없음)이면 null. */
export function brokenItems(metrics: InitialMetrics | null): InitialViolation[] | null {
return metrics?.violations?.filter((item) => !isGroundGrade(item)) ?? null;
}
/** 제약 못 지킨 구간 한 줄 — 없으면 「모두 지킴」. 서버가 목록을 안 줬으면 빈 글. */
export function violationsLine(metrics: InitialMetrics | null): string {
const items = metrics?.violations;
const items = brokenItems(metrics);
if (!items) return "";
if (!items.length) return "제약 못 지킨 구간 없음 — 모두 지킴";
const shown = items.slice(0, VIOLATION_SHOWN).map(violationText).join(" · ");
@@ -565,6 +583,25 @@ export function violationsLine(metrics: InitialMetrics | null): string {
return `제약 못 지킨 구간 ${items.length}곳: ${shown}${more}`;
}
/** 원지반 기울기(참고) 한 줄 — 최대 · 상한 넘는 곳 수. 서버가 목록을 안 줬으면 빈 글. */
export function groundGradeLine(metrics: InitialMetrics | null): string {
const items = metrics?.violations;
if (!items) return "";
const over = items.filter(isGroundGrade).length;
return `원지반 기울기(참고) 최대 ${fixed(metrics.maxGradePct, 1, "%")} · 상한 넘는 곳 ${over}`;
}
/** 원지반 기울기 넘는 곳 전부 — 참고 줄 풍선. */
export function groundGradeTitle(metrics: InitialMetrics | null): string {
return (metrics?.violations ?? []).filter(isGroundGrade).map(violationText).join("\n");
}
/** 시종점 높이 차가 상한을 넘을 때만 — 종단으로 못 맞춤(노선을 늘려야 함 · 419). 아니면 빈 글. */
export function endsLine(choice: InitialChoice | null): string {
if (!choice?.endsOverLimit) return "";
return `시종점 높이 차가 상한을 넘음(평균 ${fixed(choice.endsGradePct ?? null, 1, "%")}) — 종단으로 못 맞춤 · 노선을 늘려야 함`;
}
/** 상한을 풀었을 때만 — 「지형상 14% 로 못 이어져 상한을 23.5% 로 풂」. 아니면 빈 글. */
export function relaxedLine(choice: InitialChoice | null): string {
const bound = choice?.terrainBoundPct;
@@ -39,6 +39,10 @@ import {
SIMILAR_HINT,
similarLine,
violationsLine,
brokenItems,
endsLine,
groundGradeLine,
groundGradeTitle,
violationText,
WEIGHT_KEYS,
type CriteriaKey,
@@ -219,6 +223,8 @@ export function openRecommendModal(params: RecommendParams): void {
<div class="b05-routerec__cands" data-line="candidates" hidden></div>
</div>
<div data-line="violations" hidden></div>
<div data-line="ground" hidden></div>
<div class="is-warn" data-line="ends" hidden></div>
<div class="is-warn" data-line="relaxed" hidden></div>
<div class="is-warn" data-line="stations" hidden></div>
<div data-line="similar" hidden></div>
@@ -423,10 +429,18 @@ export function openRecommendModal(params: RecommendParams): void {
line("candidates").hidden = seen.size === 0;
const violations = violationsLine(view?.metrics ?? null);
line("violations").textContent = violations;
line("violations").title = (view?.metrics?.violations ?? [])
line("violations").title = (brokenItems(view?.metrics ?? null) ?? [])
.map((item) => `${violationText(item)} — ${item.reason}`)
.join("\n");
line("violations").hidden = !violations;
// 원지반 기울기 = 참고 줄 따로 · 시종점 높이 차 알림(421-2 · 엔진 419)
const ground = groundGradeLine(view?.metrics ?? null);
line("ground").textContent = ground;
line("ground").title = groundGradeTitle(view?.metrics ?? null);
line("ground").hidden = !ground;
const ends = endsLine(view);
line("ends").textContent = ends;
line("ends").hidden = !ends;
const relaxed = relaxedLine(view);
line("relaxed").textContent = relaxed;
line("relaxed").hidden = !relaxed;
@@ -31,10 +31,11 @@ const LOGIC_HELP: Record<ShownLogic, HelpEntry> = {
how: [
"새로 길을 찾지 않음 — 예상노선 꺾임점에 맞춘 곡선반지름(최소 이상 · 예상노선에 가장 가까운 값)으로 곡선만 넣음",
"전처리 초기 계획노선과 같은 규칙 · 곡선 가운데(MC)는 일반측점(20m) 위로 맞춤",
"종단기울기 · 계류 이격은 막지 않고, 넘은 곳만 「못 지킨 구간」에 적음",
"계류 이격은 막지 않고, 넘은 곳만 「못 지킨 구간」에 적음 · 원지반 기울기는 참고 줄",
],
when: "현장 답사로 정한 노선을 출발점으로 삼을 때 · 가장 빨리 나옴",
caution: "법정 기울기 · 계류 이격을 보장하지 않음 — 못 지킨 구간을 꼭 확인",
caution:
"계류 이격 · 기울기를 보장하지 않음 — 못 지킨 구간 · 원지반 기울기(참고) · 시종점 알림을 꼭 확인",
profile: "켬 = 균형 종단 · 끔 = 전처리 계획선처럼 계획고 = 지반고",
},
gradeline: {
@@ -87,7 +88,7 @@ const CRITERIA_HELP: Array<{ name: string; text: string }> = [
},
{
name: "종단기울기 상한(%)",
text: "이보다 가파른 구간을 만들지 않음. 비우면 임도 종류 · 지형 · 설계속도 법정 값(20km/h 일반 9% · 특수 14%). 곡선을 넣은 뒤 넘으면 상한을 조금씩 낮춰 다시 찾음",
text: "지반이 이보다 가파른 쪽으로 곧장 오르는 길은 찾지 않음. 측점(20m) 사이 원지반 기울기는 참고만 — 계획선 높이(종단)로 푸는 값이라 「못 지킨 구간」에 안 듦. 시종점 높이 차가 상한을 넘으면 종단으로도 못 맞춰 알림. 비우면 임도 종류 · 지형 · 설계속도 법정 값(20km/h 일반 9% · 특수 14%)",
},
{
name: "최소 반지름(m)",
@@ -118,7 +119,15 @@ const LINES_HELP: Array<{ name: string; text: string }> = [
},
{
name: "못 지킨 구간",
text: "제약을 넘은 측점(5곳까지 · 나머지는 「외 N곳」, 전체는 마우스를 올리면 보임)",
text: "최소 반지름 · 계류 이격 · 측점 밖 곡선을 넘은 곳만(「기준 초과」 수와 같음 · 5곳까지 · 나머지는 「외 N곳」, 전체는 마우스를 올리면 보임)",
},
{
name: "원지반 기울기(참고)",
text: "측점(20m) 사이 원지반 기울기 최대 · 상한 넘는 곳 수 — 종단으로 푸는 값이라 판정에 안 듦(넘는 곳은 마우스를 올리면 보임)",
},
{
name: "시종점 높이 차",
text: "시점 · 종점 높이 차 ÷ 노선 연장이 상한을 넘으면 알림 — 종단으로 못 맞춤 · 노선을 늘려야 함",
},
{
name: "상한을 풂",
@@ -0,0 +1,107 @@
/* 421-2 추천 창 「못 지킨 구간」 · 원지반 기울기(참고) · 시종점 알림 헬퍼 — `_Initial_Model` 을
* 그 자리에서 트랜스파일해 Node 로 돌려 줄 글을 JSON 한 벌로.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
function memoryStorage() {
const map = new Map();
return {
getItem: (key) => (map.has(key) ? map.get(key) : null),
setItem: (key, value) => map.set(key, String(value)),
removeItem: (key) => map.delete(key),
key: (index) => [...map.keys()][index] ?? null,
get length() {
return map.size;
},
_map: map,
};
}
const session = memoryStorage();
globalThis.window = { localStorage: memoryStorage(), sessionStorage: session };
globalThis.fetch = () => Promise.resolve({ ok: false });
const STUBS = { "@ui/ui_template_palette": { themeColor: (_name, fallback) => fallback } };
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
if (request.endsWith(".css")) return {};
if (request.startsWith("@ui/"))
return loadTs(path.join(ROOT, "ui_template", `${request.slice(4)}.ts`));
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
return require(request);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const model = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Initial_Model.ts"));
const nodes = [
{ x: 0, y: 0 },
{ x: 10, y: 5 },
];
const item = (kind, station, value, limit, reason) => ({ kind, station, value, limit, reason });
const now = model.normalizeRouteInitial(
{
nodes,
curves: [],
metrics: {
max_grade_pct: 35.8,
over_limit: { grade: 2, radius: 1, station: 1, stream: 0, total: 4 },
violations: [
item("grade", "No.1", 28.8, 14, "원지반 기울기 — 참고"),
item("radius", "No.3", 10, 12, "지형상 불가능 — 곡선"),
item("grade", "No.4", 18.9, 14, "원지반 기울기"),
item("station", "No.5+3.0", 3, 0, "측점 밖"),
],
},
ends_grade_pct: 16.24,
ends_over_limit: true,
},
"earthwork",
null,
);
const clean = model.normalizeRouteInitial(
{
nodes,
curves: [],
metrics: {
max_grade_pct: 12,
over_limit: { total: 1, grade: 1 },
violations: [item("grade", "No.2", 15, 14, "")],
},
ends_grade_pct: 3,
ends_over_limit: false,
},
"follow",
null,
);
const old = model.normalizeRouteInitial(
{ nodes, curves: [], metrics: { max_grade_pct: 9, over_limit: { total: 3 } } },
"gentle",
null,
);
const lines = (choice) => ({
violations: model.violationsLine(choice.metrics),
ground: model.groundGradeLine(choice.metrics),
groundTitle: model.groundGradeTitle(choice.metrics),
ends: model.endsLine(choice),
over: choice.metrics.overLimitCount,
route: model.metricsLines(choice.metrics).route,
});
process.stdout.write(JSON.stringify({ now: lines(now), clean: lines(clean), old: lines(old) }));
@@ -174,12 +174,13 @@ const labels = {
violationsOld: model.violationsLine({ ...plain.metrics, violations: null }),
violationsMany: model.violationsLine({
...plain.metrics,
// 못 지킨 곳(R) 일곱 — 원지반 기울기는 참고 줄이라 이 줄에 안 듦(421-2)
violations: Array.from({ length: 7 }, (_, i) => ({
kind: "grade",
kind: "radius",
station: `No.${i}+0.0`,
value: 15 + i,
limit: 14,
reason: "곡선 맞춤 뒤 지반 차 — 측점 사이 지반이 상한을 넘음",
value: 5 + i,
limit: 12,
reason: "곡선 둘 자리 모자람 — 교점 사이가 짧음",
})),
}),
relaxed: model.relaxedLine(plain),
@@ -0,0 +1,61 @@
"""421-2 추천 창 — 「못 지킨 구간」 = R · 계류 · 측점만 · 원지반 기울기(참고) 줄 · 시종점 알림.
./venv/Scripts/python.exe -m pytest resources/tester/test_421_2_violation_lines.py -q
"""
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
B05 = ROOT / "B05_Profile"
HELPER = Path(__file__).with_name("helper_421_2_violation_lines.cjs")
def _lines() -> dict:
run = subprocess.run(
["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8", check=True
)
return json.loads(run.stdout)
def test_못_지킨_구간은_기울기_빼고_기준_초과와_같음() -> None:
now = _lines()["now"]
assert now["violations"].startswith("제약 못 지킨 구간 2곳: No.3 R 10.0m")
assert "28.8%" not in now["violations"] and "No.5+3.0 측점 밖 3.0m" in now["violations"]
assert now["over"] == 2 and "기준 초과 2구간" in now["route"] # 서버 total 4 아님
assert now["ground"] == "원지반 기울기(참고) 최대 35.8% · 상한 넘는 곳 2"
assert now["groundTitle"].split("\n") == [
"No.1 28.8%(원지반 기울기)",
"No.4 18.9%(원지반 기울기)",
]
assert now["ends"] == (
"시종점 높이 차가 상한을 넘음(평균 16.2%) — 종단으로 못 맞춤 · 노선을 늘려야 함"
)
def test_기울기만_넘으면_모두_지킴_시종점_알림_없음() -> None:
clean = _lines()["clean"]
assert clean["violations"] == "제약 못 지킨 구간 없음 — 모두 지킴"
assert clean["over"] == 0 and clean["ground"].endswith("상한 넘는 곳 1")
assert clean["ends"] == ""
def test_옛_답은_서버_수_그대로_줄_없음() -> None:
old = _lines()["old"]
assert (
old["over"] == 3 and old["violations"] == "" and old["ground"] == "" and old["ends"] == ""
)
def test_창에_줄_두_개와_도움말() -> None:
rec = (B05 / "B05_Profile_UI_RouteEdit_Recommend.ts").read_text(encoding="utf-8")
assert '<div data-line="ground" hidden></div>' in rec
assert '<div class="is-warn" data-line="ends" hidden></div>' in rec
assert 'line("violations").title = (brokenItems(view?.metrics ?? null) ?? [])' in rec
assert "const ends = endsLine(view);" in rec
help_ts = (B05 / "B05_Profile_UI_RouteEdit_Recommend_Help.ts").read_text(encoding="utf-8")
for name in ('name: "원지반 기울기(참고)",', 'name: "시종점 높이 차",'):
assert name in help_ts
assert "이보다 가파른 구간을 만들지 않음" not in help_ts
assert "「기준 초과」 수와 같음" in help_ts
@@ -94,14 +94,15 @@ def test_비교값과_기준값_글(results):
def test_제약_못_지킨_구간과_상한_풂(results):
labels = results["labels"]
# 원지반 기울기(No.9 29.1%)는 참고 줄로 — 못 지킨 구간에 안 듦(421-2)
assert labels["violations"] == (
"제약 못 지킨 구간 3곳: No.9+0.0 29.1%(지형상 불가능)"
" · No.20+0.0 R 9.5m(곡선 둘 자리 모자람) · No.30+0.0 계류 32.0m(계류 버퍼 안)"
"제약 못 지킨 구간 2곳: 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%(곡선 맞춤 뒤 지반 차)"
"제약 못 지킨 구간 7곳: No.0+0.0 R 5.0m(곡선 둘 자리 모자람)"
)
assert labels["violationsMany"].endswith(" 외 2곳")
assert labels["relaxed"] == "지형상 14.0% 로 못 이어져 상한을 23.5% 로 풂"
@@ -382,5 +383,6 @@ def test_같은_노선_알림_줄(results):
def test_비중_계산_넘은_구간_사유_그대로(results):
six = results["sixth"]
assert six["weightedViolation"] == "제약 못 지킨 구간 1곳: No.9+0.0 29.1%(비중 계산)"
# 원지반 기울기만 넘음 = 못 지킨 곳 없음(421-2) · 사유 글은 참고 줄 풍선에 그대로
assert six["weightedViolation"] == "제약 못 지킨 구간 없음 — 모두 지킴"
assert six["weightedViolationReason"] == "No.9+0.0 29.1%(비중 계산)"