refactor(472 마무리): 종단도 계획고 [저장] 조각을 design_profile_save 로 떼어 냄 — design_profile 708 → 683 줄 · 동작 같음

- design_profile_save.ts 새 파일 — 한 본문 [저장] 조각 · [확정] 직전 저장 · 저장 뒤 서버 선형 넘김
- 높이 캐스케이드 설명을 B05_Profile_UI_Profile_Heights 머리로 옮김
- 팝아웃 시험 목록에 새 파일 · 저장 조각 시험(test_472_6_profile_save) 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 15:33:08 +09:00
co-authored by Claude Opus 5.5
parent cfdcf0aed7
commit 8d577ef330
6 changed files with 171 additions and 48 deletions
+23 -48
View File
@@ -23,11 +23,6 @@ import {
toDesignProfile,
} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data";
import { showToast } from "@ui/ui_template_elements";
import {
saveProfileAlignment,
type ProfileAlignmentSaveResponse,
} from "../../B05_Profile/B05_Profile_Api_Fetch";
import type { SavePart } from "./design_save_api";
import { hasStaleDesigns } from "../../B06_Section/B06_Section_UI_Section_Common";
import {
blocksMinCover,
@@ -88,12 +83,12 @@ import * as pagePipeline from "./design_pipeline_page";
import type { ElevationWindowResult } from "@util/common_util_chart_ywindow";
import { frameOf } from "@util/common_util_view_window";
import type { DesignSideBoard, DesignView } from "./design_profile_slots";
import { noticeDroppedEdits } from "./design_save";
import { createProfileOverlays } from "./design_profile_overlays";
import { bindProfileViewport, createSelectionReveal } from "./design_profile_viewport";
import { CROSS_PREVIEW_DEBOUNCE_MS, type DesignProfileContext } from "./design_profile_context";
import { pageRouteId } from "./design_page_route";
import { createProfileFrame } from "./design_profile_frame"; // 판 뼈대 · 종단 CSS(700줄 한계)
import { createProfileSave } from "./design_profile_save"; // 계획고 [저장] 조각(700줄 한계)
export type {
DesignOverlayBoard,
@@ -303,29 +298,26 @@ export function createDesignProfile(ctx: DesignProfileContext) {
const listed = (): AlignmentBase | null =>
listedBase(storedBase, detail, planted ? irregularStations : null);
/** 계획고 [저장] 조각(472-6) — 저장 뒤 서버 선형으로 기준을 갈아 다시 그림. */
const alignmentSave = createProfileSave({
projectId,
routeId: () => routeId,
edits: () => store.edits(),
dirty: () => store.dirty(),
previewing: () => store === preview, // 노선 초안 미리보기 편집은 안 보냄
adopt: (next) => {
if (next?.base_pvi?.length) {
storedBase = toAlignmentBase(next);
base = listed();
alignment = buildAlignment(base!, store.edits());
if (detail) detail.longitudinal.profile_alignment = next;
}
store.markSaved();
draw();
},
});
/** 편집 후 다시 그린다 — 길게 누르기(초당 10회)도 한 프레임에 한 번만 그림. */
/** 한 본문 [저장] 조각(472-6) — 편집 없음 · 노선 초안 미리보기면 null · 저장 뒤 서버 선형으로 다시 그림. */
function alignmentSavePart(): SavePart | null {
if (!routeId || !store.dirty() || store === preview) return null; // 노선 초안 미리보기 편집은 안 보냄
return {
body: { route_id: routeId, ...store.edits() },
after: (response) => adoptSaved(response as unknown as ProfileAlignmentSaveResponse),
};
}
function adoptSaved(saved: ProfileAlignmentSaveResponse): void {
noticeDroppedEdits(saved);
const next = saved.profile_alignment as ProfileAlignment | undefined;
if (next?.base_pvi?.length) {
storedBase = toAlignmentBase(next);
base = listed();
alignment = buildAlignment(base!, store.edits());
if (detail) detail.longitudinal.profile_alignment = next;
}
store.markSaved();
draw();
}
function rebuild(): void {
if (base) alignment = buildAlignment(base, store.edits());
pagePipeline.pipelineGrade(projectId, store.edits());
@@ -361,15 +353,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
// 노선 초안 동안은 저장 노선 횡단을 안 고침 — 새 노선 횡단은 계산 묶음 cross(346-2)
const scheduleCrossPreview = (): void => void (store !== preview && crossPreview.schedule());
/**
* 높이 캐스케이드(2026-08-05 사용자 확정): 자리가 모자라면 ① 종단이 먼저 최소까지
* 줄고 → ② 테이블·유토곡선이 **같이**(비례) 각자 최소까지 줄고 → ③ 셋 다 최소면
* 메인 패널 리사이저의 동적 하한이 더 못 내려가게 막는다. 반대로 서브패널을 갑자기
* 펼쳐 자리가 모자라면 메인 패널 높이를 키운다(deficit 처리).
*
* draw()(전체 재구성)와 리사이즈 중 경량 동기화가 **같은 계산**을 쓴다 — 끌 때는
* 이 함수만 프레임마다 돌리고, 무거운 차트·테이블 재구성은 손을 뗀 뒤 한 번만 한다.
*/
/** 높이 캐스케이드 — 규칙은 `_Profile_Heights` 머리(700줄 한계). */
const heights = createHeightCascade({
root,
body,
@@ -642,18 +626,9 @@ export function createDesignProfile(ctx: DesignProfileContext) {
drawSoon();
},
/** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */
async save(): Promise<void> {
const part = alignmentSavePart();
if (!part) return;
await part.after?.(
(await saveProfileAlignment(projectId, routeId!, store.edits())) as unknown as Record<
string,
unknown
>,
);
},
save: () => alignmentSave.save(),
/** 한 본문 [저장] 조각(472-6) — 편집 없음 · 노선 초안 미리보기면 null · 저장 뒤 서버 선형으로 다시 그림. */
savePart: () => alignmentSavePart(),
savePart: () => alignmentSave.savePart(),
clear() {
detail = null;
base = null;
+52
View File
@@ -0,0 +1,52 @@
/* =============================================================================
* A00_Common/design/design_profile_save.ts
* 종단도 계획고 편집 저장 — 한 본문 [저장] 조각(472-6) · [확정] 직전 따로 저장.
* 편집 없음 · 노선 초안 미리보기면 안 보냄 · 저장 뒤 서버 선형은 `adopt` 로 종단도에 넘김.
* `design_profile.ts` 에서 떼어 냄(700줄 한계).
* ========================================================================== */
import {
saveProfileAlignment,
type ProfileAlignmentSaveResponse,
} from "../../B05_Profile/B05_Profile_Api_Fetch";
import type {
AlignmentEdits,
ProfileAlignment,
} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
import type { SavePart } from "./design_save_api";
import { noticeDroppedEdits } from "./design_save";
export function createProfileSave(deps: {
projectId: string;
routeId: () => number | null;
edits: () => AlignmentEdits;
dirty: () => boolean;
/** 노선 초안 미리보기 편집 중 — 안 보냄. */
previewing: () => boolean;
/** 서버가 돌려준 선형(없을 수 있음) — 기준 갈아끼움 · 저장 표시 · 다시 그림. */
adopt: (saved: ProfileAlignment | undefined) => void;
}) {
/** 한 본문 [저장] 조각 — 편집 없음 · 노선 초안 미리보기면 null. */
function savePart(): SavePart | null {
const routeId = deps.routeId();
if (!routeId || !deps.dirty() || deps.previewing()) return null;
return {
body: { route_id: routeId, ...deps.edits() },
after: (response) => {
const saved = response as unknown as ProfileAlignmentSaveResponse;
noticeDroppedEdits(saved);
deps.adopt(saved.profile_alignment as ProfileAlignment | undefined);
},
};
}
/** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */
async function save(): Promise<void> {
const part = savePart();
if (!part) return;
const saved = await saveProfileAlignment(deps.projectId, deps.routeId()!, deps.edits());
await part.after?.(saved as unknown as Record<string, unknown>);
}
return { savePart, save };
}
@@ -60,6 +60,15 @@ export interface HeightCascade {
scheduleLightSync: () => void;
}
/**
* 높이 캐스케이드(2026-08-05 사용자 확정): 자리가 모자라면 ① 종단이 먼저 최소까지
* 줄고 → ② 테이블·유토곡선이 **같이**(비례) 각자 최소까지 줄고 → ③ 셋 다 최소면
* 메인 패널 리사이저의 동적 하한이 더 못 내려가게 막는다. 반대로 서브패널을 갑자기
* 펼쳐 자리가 모자라면 메인 패널 높이를 키운다(deficit 처리).
*
* 종단도 draw()(전체 재구성)와 리사이즈 중 경량 동기화가 **같은 계산**을 쓴다 — 끌 때는
* 이 함수만 프레임마다 돌리고, 무거운 차트·테이블 재구성은 손을 뗀 뒤 한 번만 한다.
*/
export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade {
let lightSyncPending = false;
@@ -0,0 +1,56 @@
/* 472 마무리 — 종단도 계획고 [저장] 조각(`design_profile_save.ts`) 진짜 본문 · 서버 창구 · 알림만 가짜.
* 결과 = 표준출력 JSON. 짝 = `test_472_6_profile_save.py`. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const calls = [];
const STUBS = {
B05_Profile_Api_Fetch: {
saveProfileAlignment: async (projectId, routeId, edits) => {
calls.push(["put", projectId, routeId, edits]);
return { profile_alignment: { base_pvi: [1] }, dropped_edit_chainages: [] };
},
},
design_save: {
noticeDroppedEdits: (saved) => calls.push(["notice", saved.dropped_edit_chainages]),
},
};
const file = path.join(ROOT, "A00_Common", "design", "design_profile_save.ts");
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
const req = (request) => STUBS[Object.keys(STUBS).find((name) => request.endsWith(name))] ?? {};
new Function("exports", "module", "require", js)(box.exports, box, req);
(async () => {
const state = { routeId: 7, dirty: true, previewing: false };
const adopted = [];
const edits = { station_offsets: { 10: 0.5 }, curve_radii: {} };
const save = box.exports.createProfileSave({
projectId: "p",
routeId: () => state.routeId,
edits: () => edits,
dirty: () => state.dirty,
previewing: () => state.previewing,
adopt: (next) => adopted.push(next ?? null),
});
const nulls = {};
for (const [name, patch] of [
["clean", { dirty: false }],
["preview", { previewing: true }],
["noRoute", { routeId: null }],
]) {
Object.assign(state, { routeId: 7, dirty: true, previewing: false }, patch);
nulls[name] = save.savePart();
}
Object.assign(state, { routeId: 7, dirty: true, previewing: false });
const part = save.savePart();
await part.after({ profile_alignment: { base_pvi: [2] }, dropped_edit_chainages: [3] });
await save.save();
state.dirty = false;
await save.save(); // 편집 없음 = 안 보냄
console.log(JSON.stringify({ nulls, body: part.body, adopted, calls }));
})();
@@ -22,6 +22,7 @@ ROOT = os.path.dirname(os.path.dirname(HERE))
# 새 창으로 가는 종단 패널의 화면 코드
POPOUT_FILES = [
"A00_Common/design/design_profile.ts",
"A00_Common/design/design_profile_save.ts",
"A00_Common/design/design_profile_viewport.ts",
"B05_Profile/B05_Profile_UI_Profile_Wheel.ts",
"B05_Profile/B05_Profile_UI_Profile_Heights.ts",
@@ -0,0 +1,30 @@
"""472 마무리 — 종단도 계획고 [저장] 조각을 `design_profile_save.ts` 로 떼어 냄(700줄 한계 · 동작 같음).
`helper_472_6_profile_save.cjs` 가 진짜 본문을 돌림 — 편집 없음 · 노선 초안 미리보기 · 노선 없음 = 조각 없음 ·
조각 본문 = 노선 id + 편집 · 저장 뒤 버린 자리 알림 → 서버 선형 넘김 · [확정] 직전 저장 = 같은 창구 한 번.
./venv/Scripts/python.exe -m pytest resources/tester/test_472_6_profile_save.py -q
"""
import json
import os
import subprocess
HERE = os.path.dirname(os.path.abspath(__file__))
def test_계획고_저장_조각() -> None:
proc = subprocess.run(
["node", os.path.join(HERE, "helper_472_6_profile_save.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
r = json.loads(proc.stdout)
edits = {"station_offsets": {"10": 0.5}, "curve_radii": {}}
assert r["nulls"] == {"clean": None, "preview": None, "noRoute": None}
assert r["body"] == {"route_id": 7, **edits}
assert r["adopted"] == [{"base_pvi": [2]}, {"base_pvi": [1]}]
assert r["calls"] == [["notice", [3]], ["put", "p", 7, edits], ["notice", []]]