diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts index 33c364519..0fa86ec1c 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts @@ -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, ); } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts index 0c46b03b1..94a4312a6 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts @@ -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 = { expected: "예상", earthwork: "토공", @@ -218,6 +231,7 @@ export function openRecommendModal(params: RecommendParams): void {
+
@@ -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; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Profile.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Profile.ts index 80b3ad91f..3ab830d7c 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Profile.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Profile.ts @@ -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; + 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 | null, + initial: Pick | 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 + ? ` + ` + : ""; return ` - - `; + role="img" aria-label="종단 미리 보기 — 지반선 · 계획선 · 적용될 초기 종단"> + + ${initialPath}`; +} + +/** 그림 아래 범례 — 그린 선만. */ +export function profileLegend(hasPlan: boolean, hasInitial: boolean): string { + const item = (cls: string, name: string): string => `${name}`; + return `
${[ + item("is-ground", "지반선"), + hasPlan ? item("is-plan", "균형 계획선") : "", + hasInitial ? item("is-initial", "적용될 초기 종단") : "", + ].join("")}
`; } const CANDIDATE_COLUMNS: DataColumn[] = [ diff --git a/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css b/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css index 9b2a89327..f0c24058b 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css @@ -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%; diff --git a/resources/tester/helper_476_2_initial_profile.cjs b/resources/tester/helper_476_2_initial_profile.cjs new file mode 100644 index 000000000..0c6a48967 --- /dev/null +++ b/resources/tester/helper_476_2_initial_profile.cjs @@ -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]] }), + }), +); diff --git a/resources/tester/test_421_recommend_profile.py b/resources/tester/test_421_recommend_profile.py index 605c8dee8..31f964a0d 100644 --- a/resources/tester/test_421_recommend_profile.py +++ b/resources/tester/test_421_recommend_profile.py @@ -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: diff --git a/resources/tester/test_476_2_recommend_initial_line.py b/resources/tester/test_476_2_recommend_initial_line.py new file mode 100644 index 000000000..892a0383c --- /dev/null +++ b/resources/tester/test_476_2_recommend_initial_line.py @@ -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 '' 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