feat(476-2 추천 초기 종단): 추천 모달에 적용될 초기 종단 선 · 한 줄 · [적용] 확인 글

- 서버 initial_profile 펴기(세션에 안 적음) · 실패면 이유 한 줄
- 종단 그림에 초기 종단 선(주황 점선 · 끔이면 지반선 + 초기 종단) · 범례
- 한 줄 = 꼭짓점 · 종단곡선 · 직선화 수 · 최대 종단경사(L)
- [적용] = 「종단 손 편집은 새 초기 종단으로 바뀜」 확인 뒤
- 시험 test_476_2 · test_421 [적용] 단언 = 확인 창 뒤 고른 후보

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 16:23:06 +09:00
co-authored by Claude Opus 5.5
parent b14de92621
commit f3322d1575
7 changed files with 348 additions and 24 deletions
@@ -12,7 +12,12 @@ import type {
} from "./B05_Profile_Api_RouteInitial";
import { flattenServerPlan } from "./logic/B05_Profile_UI_RouteEdit_Edits";
import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
import { parseBalance, type BalanceProfile } from "./B05_Profile_UI_RouteEdit_Recommend_Profile";
import {
parseBalance,
parseInitialProfile,
type BalanceProfile,
type InitialProfile,
} from "./B05_Profile_UI_RouteEdit_Recommend_Profile";
type Vertex = [number, number];
@@ -199,6 +204,8 @@ export interface InitialChoice {
stored?: boolean;
/** 균형 종단(계획서 63-4) — 모달 미리 보기에만. 캐시에는 안 적는다(`writeInitialChoice`). */
balance?: BalanceProfile | null;
/** 적용될 초기 종단(476) — 모달 미리 보기에만 · 실패면 `{ error }`. 캐시에는 안 적는다. */
initialProfile?: InitialProfile | { error: string } | null;
/** 서버가 짠 선(원호 정점 · 계획서 351-2) — 모달 미리 보기에만. 캐시에는 안 적는다. */
line?: Vertex[] | null;
/** MC 가 일반측점 밖인 곡선 수(계획서 351-2) — 서버 `off_station_curves`. 옛 답이면 null. */
@@ -359,6 +366,7 @@ export function normalizeRouteInitial(
// 서버가 셈에 쓴 값이 먼저(420) · 옛 서버면 보낸 값
withProfile: typeof body.with_profile === "boolean" ? body.with_profile : withProfile,
balance: parseBalance(m.profile),
initialProfile: parseInitialProfile(body.initial_profile),
line: Array.isArray(body.planned)
? (body.planned as unknown[][]).map((xy): Vertex => [Number(xy[0]), Number(xy[1])])
: null,
@@ -621,10 +629,10 @@ export function readInitialChoice(projectId: string): InitialChoice | null {
}
export function writeInitialChoice(projectId: string, choice: InitialChoice | null): void {
// 균형 종단 · 서버 선은 편집 창 안에서만(계획서 63-4 · 351-2) — 캐시에 안 싣는다.
// 균형 종단 · 초기 종단 · 서버 선은 편집 창 안에서만(계획서 63-4 · 476 · 351-2) — 캐시에 안 싣는다.
writeState(
"routeedit-initial",
choice && { ...choice, balance: undefined, line: undefined },
choice && { ...choice, balance: undefined, initialProfile: undefined, line: undefined },
projectId,
);
}
@@ -13,10 +13,17 @@
* 서버 부르기 · 캐시 · 「현재 추천」은 `_Initial` 몫이고 여기는 화면만 만든다.
* 균형 종단(계획서 63-4) — 후보마다 서버가 세운 종단의 한 줄 · 그림 · 셈한 후보 표는 이 모달
* 안에서만 보인다(`_Recommend_Profile`) · 닫으면 버린다.
* 적용될 초기 종단(476) — 서버가 [적용]과 같은 코드로 세운 종단을 같은 그림에 선 · 한 줄로 더한다
* (끔이어도) · [적용]은 「종단 손 편집이 새 초기 종단으로 바뀜」 확인 뒤.
* ========================================================================== */
import { openModal } from "@ui/ui_template_modal";
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import {
createButton,
createInputField,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { toggleButton } from "./B05_Profile_UI_Panel_Fields";
import type {
PrecomputeProgress,
@@ -61,6 +68,8 @@ import {
import {
balanceLine,
candidatesTable,
initialLine,
profileLegend,
profileSvg,
type BalanceProfile,
} from "./B05_Profile_UI_RouteEdit_Recommend_Profile";
@@ -78,6 +87,10 @@ const PILL_TITLE: Record<(typeof PILLS)[number], string> = {
weighted: "예상 · 토공 · 기울기 비중(합 100)으로 셈한 노선 + 종단",
};
/** [적용] 확인 글(476) — 같은 노선이어도 종단을 새로 세운다. */
const APPLY_CONFIRM =
"이 노선을 저장하고 미리 본 초기 종단으로 종단을 새로 세웁니다 — 종단 손 편집은 새 초기 종단으로 바뀝니다. 적용할까요?";
const WEIGHT_NAME: Record<WeightKey, string> = {
expected: "예상",
earthwork: "토공",
@@ -218,6 +231,7 @@ export function openRecommendModal(params: RecommendParams): void {
<div data-line="metrics"></div>
<div data-line="metrics-work"></div>
<div data-line="balance" hidden></div>
<div data-line="initial" hidden></div>
<div class="b05-routerec__balance">
<div class="b05-routerec__profile" data-line="profile" hidden></div>
<div class="b05-routerec__cands" data-line="candidates" hidden></div>
@@ -423,8 +437,16 @@ export function openRecommendModal(params: RecommendParams): void {
const balance = view?.balance ?? null;
line("balance").textContent = balanceLine(balance);
line("balance").hidden = !balance;
line("profile").innerHTML = profileSvg(balance);
line("profile").hidden = !balance;
// 적용될 초기 종단(476) — 끔이면 지반선 + 초기 종단 · 실패면 이유 한 줄만
const initial = view?.initialProfile ?? null;
const initialShape = initial && !("error" in initial) ? initial : null;
line("initial").textContent = initialLine(initial);
line("initial").classList.toggle("is-warn", !!initial && "error" in initial);
line("initial").hidden = !initial;
line("profile").innerHTML =
profileSvg(balance, initialShape) +
profileLegend(!!balance?.planM.length, !!initialShape);
line("profile").hidden = !balance && !initialShape;
line("candidates").innerHTML = candidatesTable([...seen]);
line("candidates").hidden = seen.size === 0;
const violations = violationsLine(view?.metrics ?? null);
@@ -597,10 +619,14 @@ export function openRecommendModal(params: RecommendParams): void {
});
rerun.addEventListener("click", () => void run(true));
applyButton.addEventListener("click", () => {
if (!preview) return;
params.onApply(preview);
closed = true;
close();
const picked = preview;
if (!picked) return;
void showConfirmDialog(APPLY_CONFIRM, "적용").then((ok) => {
if (!ok || closed || preview !== picked) return;
params.onApply(picked);
closed = true;
close();
});
});
closeButton.addEventListener("click", () => {
closed = true;
@@ -6,6 +6,8 @@
* 이 값은 **편집 창 안에서만** 산다 — 캐시(`routeedit-initial`)에 적지 않고(`writeInitialChoice`
* 가 뺀다) 저장 · initial_snapshot · 확정 종단과 무관하다. 확정 종단은 [확인] 체인이 지금 규칙
* (계획고 = 지반고)으로 다시 세운다. 계산 자리 = 서버 단독 — 여기는 받은 값을 그리기만 한다.
* 적용될 초기 종단(476 · 요구 276) — 서버가 [적용]과 같은 코드로 후보마다 세운 종단
* (`initial_profile` · 438 직선화 · 종단곡선 포함)을 펴서 같은 그림에 선 하나 · 한 줄로 더한다.
* ========================================================================== */
import { renderDataTableHtml, type DataColumn } from "@ui/ui_template_data_table";
@@ -62,6 +64,69 @@ export function parseBalance(raw: unknown): BalanceProfile | null {
};
}
/** 후보 하나의 적용될 초기 종단 — 서버 `initial_profile`(476-1). */
export interface InitialProfile {
withProfile: boolean;
stationsM: number[];
groundM: number[];
/** 측점 계획고. */
planM: number[];
/** 종단 표본 간격으로 편 선(종단곡선 포함) — [누가거리, 계획고]. */
line: Array<[number, number]>;
pviCount: number;
curveCount: number;
/** 438 로 직선화된 꼭짓점 수. */
straightened: number;
maxGradePct: number;
curveLengthM: number | null;
warnings: string[];
}
/** 서버 `initial_profile` 을 편다 — 셈 실패면 `{ error }` · 없거나(옛 답) 꼴이 틀리면 null. */
export function parseInitialProfile(raw: unknown): InitialProfile | { error: string } | null {
if (!raw || typeof raw !== "object") return null;
const p = raw as Record<string, unknown>;
if (typeof p.error === "string") return { error: p.error };
const stationsM = numbers(p.stations_m);
const groundM = numbers(p.ground_m);
const planM = numbers(p.plan_m);
const line = Array.isArray(p.line)
? p.line
.filter((xy): xy is unknown[] => Array.isArray(xy))
.map((xy) => [num(xy[0]), num(xy[1])])
.filter((xy): xy is [number, number] => xy[0] !== null && xy[1] !== null)
: [];
if (stationsM.length < 2 || groundM.length !== stationsM.length) return null;
if (planM.length !== stationsM.length || line.length < 2) return null;
return {
withProfile: p.with_profile === true,
stationsM,
groundM,
planM,
line,
pviCount: num(p.pvi_count) ?? 0,
curveCount: num(p.curve_count) ?? 0,
straightened: num(p.straightened) ?? 0,
maxGradePct: num(p.max_grade_pct) ?? 0,
curveLengthM: num(p.curve_length_m),
warnings: Array.isArray(p.warnings) ? p.warnings.map(String) : [],
};
}
/** 한 줄 — 「초기 종단: 꼭짓점 12 · 종단곡선 10 · 직선화 3 · 최대 종단경사 11.2%(L 20)」 · 실패면 이유. */
export function initialLine(initial: InitialProfile | { error: string } | null): string {
if (!initial) return "";
if ("error" in initial) return `초기 종단을 셈하지 못했습니다 — ${initial.error}`;
const parts = [
`초기 종단: 꼭짓점 ${initial.pviCount} · 종단곡선 ${initial.curveCount}`,
`직선화 ${initial.straightened}`,
`최대 종단경사 ${pct(initial.maxGradePct)}${
initial.curveLengthM !== null ? `(L ${initial.curveLengthM})` : ""
}`,
];
return [parts.join(" · "), ...initial.warnings].join(" · ");
}
const m3 = (value: number): string => `${Math.round(value).toLocaleString("ko-KR")}㎥`;
const pct = (value: number): string => `${value.toFixed(1)}%`;
const signedM3 = (value: number): string => `${value > 0 ? "+" : ""}${m3(value)}`;
@@ -84,28 +149,46 @@ const PROFILE_W = 600;
const PROFILE_H = 96;
const PAD = 6;
/** 종단 그림(SVG) — 지반선 · 균형 계획선. 가로 = 측점 · 세로 = 표고(창에 맞춰 늘림).
* 계획선이 비면(종단 반영 끔) 지반선만. */
/** 종단 그림(SVG) — 지반선 · 균형 계획선 · 적용될 초기 종단. 가로 = 측점 · 세로 = 표고(창에 맞춰
* 늘림). 계획선이 비면(종단 반영 끔) 지반선 · 초기 종단만 · 균형 종단이 없으면 초기 종단의 지반선. */
export function profileSvg(
balance: Pick<BalanceProfile, "stationsM" | "groundM" | "planM"> | null,
initial: Pick<InitialProfile, "stationsM" | "groundM" | "line"> | null = null,
): string {
if (!balance) return "";
const { stationsM, groundM, planM } = balance;
const total = stationsM[stationsM.length - 1] || 1;
const low = Math.min(...groundM, ...planM);
const span = Math.max(Math.max(...groundM, ...planM) - low, 1);
const path = (values: number[]): string =>
values
const base = balance ?? (initial && { ...initial, planM: [] });
if (!base) return "";
const { stationsM, groundM, planM } = base;
const lineS = initial?.line.map(([s]) => s) ?? [];
const lineZ = initial?.line.map(([, z]) => z) ?? [];
const total = Math.max(stationsM[stationsM.length - 1], ...lineS) || 1;
const low = Math.min(...groundM, ...planM, ...lineZ);
const span = Math.max(Math.max(...groundM, ...planM, ...lineZ) - low, 1);
const path = (xs: number[], zs: number[]): string =>
zs
.map((z, i) => {
const x = PAD + ((PROFILE_W - 2 * PAD) * stationsM[i]) / total;
const x = PAD + ((PROFILE_W - 2 * PAD) * xs[i]) / total;
const y = PROFILE_H - PAD - ((PROFILE_H - 2 * PAD) * (z - low)) / span;
return `${i ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)}`;
})
.join("");
const initialPath = initial
? `
<path class="is-initial" d="${path(lineS, lineZ)}" vector-effect="non-scaling-stroke" />`
: "";
return `<svg viewBox="0 0 ${PROFILE_W} ${PROFILE_H}" preserveAspectRatio="none"
role="img" aria-label="균형 종단 미리 보기 — 지반선과 계획선">
<path class="is-ground" d="${path(groundM)}" vector-effect="non-scaling-stroke" />
<path class="is-plan" d="${planM.length ? path(planM) : ""}" vector-effect="non-scaling-stroke" /></svg>`;
role="img" aria-label="종단 미리 보기 — 지반선 · 계획선 · 적용될 초기 종단">
<path class="is-ground" d="${path(stationsM, groundM)}" vector-effect="non-scaling-stroke" />
<path class="is-plan" d="${planM.length ? path(stationsM, planM) : ""}" vector-effect="non-scaling-stroke" />${initialPath}</svg>`;
}
/** 그림 아래 범례 — 그린 선만. */
export function profileLegend(hasPlan: boolean, hasInitial: boolean): string {
const item = (cls: string, name: string): string => `<span><i class="${cls}"></i>${name}</span>`;
return `<div class="b05-routerec__plegend">${[
item("is-ground", "지반선"),
hasPlan ? item("is-plan", "균형 계획선") : "",
hasInitial ? item("is-initial", "적용될 초기 종단") : "",
].join("")}</div>`;
}
const CANDIDATE_COLUMNS: DataColumn[] = [
@@ -500,6 +500,38 @@
stroke-width: 2;
}
/* 적용될 초기 종단(476) — 종단곡선 포함 · [적용]하면 이 선이 작업본 · 초기값. */
.b05-routerec__profile .is-initial {
fill: none;
stroke: var(--map-route, #f97316);
stroke-width: 2;
stroke-dasharray: 6 3;
}
.b05-routerec__plegend {
display: flex;
gap: var(--spacing-12);
margin-top: var(--spacing-4, 4px);
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.b05-routerec__plegend i {
display: inline-block;
width: 16px;
margin-right: var(--spacing-4, 4px);
vertical-align: middle;
border-top: 2px solid var(--color-text-secondary);
}
.b05-routerec__plegend i.is-plan {
border-top-color: var(--color-primary);
}
.b05-routerec__plegend i.is-initial {
border-top: 2px dashed var(--map-route, #f97316);
}
/* 이 모달에서 셈한 후보 표 — 절 · 성 · 반입 · 최대 종단경사. */
.b05-routerec__cands table {
width: 100%;
@@ -0,0 +1,112 @@
/* 476-2 추천 모달 「적용될 초기 종단」 헬퍼 — `_Initial_Model` · `_Recommend_Profile` 을 그 자리에서
* 트랜스파일해 Node 로 돌려 서버 `initial_profile` 펴기 · 한 줄 · 그림 · 캐시 빼기를 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 prof = loadTs(
path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Recommend_Profile.ts"),
);
// 476-1 응답 꼴(분석 2.3) — 결과 옆 `initial_profile`.
const initialRaw = {
fingerprint: "abc",
with_profile: false,
curve_length_m: 20,
stations_m: [0, 20, 40],
ground_m: [100, 103, 101],
plan_m: [100, 102, 101],
line: [
[0, 100],
[10, 101],
[20, 102],
[30, 101.5],
[40, 101],
],
pvi_count: 3,
curve_count: 1,
straightened: 2,
max_grade_pct: 10.04,
warnings: [],
seconds: 0.1,
};
const answer = {
nodes: [
{ x: 0, y: 0 },
{ x: 40, y: 0 },
],
curves: [],
initial_profile: initialRaw,
};
const off = model.normalizeRouteInitial(answer, "follow", null, null, false);
model.writeInitialChoice("p-476", off);
const failed = model.normalizeRouteInitial(
{ ...answer, initial_profile: { error: "지반고 종단 — 측점이 모자랍니다" } },
"follow",
null,
null,
false,
);
const balance = { stationsM: [0, 20, 40], groundM: [100, 103, 101], planM: [100, 101, 101] };
process.stdout.write(
JSON.stringify({
parsed: off.initialProfile,
cached: model.readInitialChoice("p-476"),
line: prof.initialLine(off.initialProfile),
svgOff: prof.profileSvg(null, off.initialProfile),
svgOn: prof.profileSvg(balance, off.initialProfile),
svgOld: prof.profileSvg(balance),
legendOff: prof.profileLegend(false, true),
failed: failed.initialProfile,
failedLine: prof.initialLine(failed.initialProfile),
missing: model.normalizeRouteInitial({ ...answer, initial_profile: undefined }, "follow", null)
.initialProfile,
shortLine: prof.parseInitialProfile({ ...initialRaw, line: [[0, 100]] }),
}),
);
@@ -43,7 +43,7 @@ def test_창_단추와_재계산() -> None:
assert "rerun.disabled = busy || invalid.size > 0;" in rec # 늘 켜짐
assert 'rerun.addEventListener("click", () => void run(true));' in rec
assert "const withProfile = profileOn;" in rec
assert "params.onApply(preview);" in rec
assert "params.onApply(picked);" in rec # 476 — 확인 창 뒤 미리 본 그 후보
def test_요청과_적용에_종단_반영() -> None:
@@ -0,0 +1,63 @@
"""476-2 추천 모달 — 「적용될 초기 종단」 선 · 한 줄 · [적용] 확인 글(요구 276).
./venv/Scripts/python.exe -m pytest resources/tester/test_476_2_recommend_initial_line.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_476_2_initial_profile.cjs")
def _out() -> dict:
return json.loads(
subprocess.run(
["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8", check=True
).stdout
)
def test_펴기와_캐시_빼기() -> None:
out = _out()
p = out["parsed"]
assert p["withProfile"] is False and p["curveLengthM"] == 20
assert p["pviCount"] == 3 and p["curveCount"] == 1 and p["straightened"] == 2
assert p["line"][3] == [30, 101.5] and p["planM"] == [100, 102, 101]
assert "initialProfile" not in out["cached"] # 세션에 안 적음
assert out["missing"] is None # 옛 서버 답
assert out["shortLine"] is None # 선 점 하나 = 꼴 틀림
def test_한_줄과_실패_글() -> None:
out = _out()
assert out["line"] == "초기 종단: 꼭짓점 3 · 종단곡선 1 · 직선화 2 · 최대 종단경사 10.0%(L 20)"
assert out["failed"] == {"error": "지반고 종단 — 측점이 모자랍니다"}
assert out["failedLine"].startswith("초기 종단을 셈하지 못했습니다 — 지반고 종단")
def test_그림_선() -> None:
out = _out()
# 끔 = 지반선 + 초기 종단(계획선 빔)
assert 'class="is-ground" d="M6.0 90.0L300.0 6.0L594.0 62.0"' in out["svgOff"]
assert 'class="is-plan" d=""' in out["svgOff"]
initial = 'class="is-initial" d="M6.0 90.0L153.0 62.0L300.0 34.0L447.0 48.0L594.0 62.0"'
assert initial in out["svgOff"] and initial in out["svgOn"]
assert 'class="is-plan" d="M6.0 90.0L300.0 62.0L594.0 62.0"' in out["svgOn"]
assert "is-initial" not in out["svgOld"] # 옛 답 = 지반 · 균형만
assert "is-plan" not in out["legendOff"] and "적용될 초기 종단" in out["legendOff"]
def test_모달_배선과_확인_글() -> None:
rec = (B05 / "B05_Profile_UI_RouteEdit_Recommend.ts").read_text(encoding="utf-8")
assert '<div data-line="initial" hidden></div>' in rec
assert "profileSvg(balance, initialShape)" in rec
assert 'line("profile").hidden = !balance && !initialShape;' in rec
apply = rec[rec.index('applyButton.addEventListener("click"') :][:400]
assert 'showConfirmDialog(APPLY_CONFIRM, "적용")' in apply
assert apply.index("showConfirmDialog") < apply.index("params.onApply(picked);")
assert "종단 손 편집은 새 초기 종단으로 바뀝니다" in rec
css = (B05 / "B05_Profile_UI_Style_RouteRecommend.css").read_text(encoding="utf-8")
assert ".b05-routerec__profile .is-initial" in css and "var(--map-route" in css