2026-09-02 사용자 지시 9건 반영. 화면 실조작 검증은 다음 세션 몫 (사용자 지시로 코딩까지만 진행). 1. 초기 계획선 = 전체 측점 폴리라인 — `design_ground_following_profile()` 신설. 모든 측점을 변화점으로 잡고 계획고 = 원지반고, 라운드는 R을 지정한 자리에만 (`build_curves(only_explicit=)`·`build_alignment(only_explicit_curves=)`). basis `ground_polyline`. 관 정착 선형은 폴백으로 내림. 2. 구간 쉬프트(⬆⬇) 삭제. 3. [직선화] 신설 — `B05_Profile_UI_Profile_Straighten.ts`. 두 측점의 라운드에 탄젠트한 직선으로 대체하고 사이 라운드 삭제. 직선 틸팅 시 가운데 라운드 + 양측 탄젠트 재구성. 4. [쉬프트] 신설 — 직선 구간을 기하에서 되읽어(`detectStraightRun`) 복수 선택, 최외곽 라운드 중심 기준 상·하 평행이동. 5. 방향키 조작 — 상하 0.1m 계획고, 좌우 0.1m 누가거리(구조물·비정규 측점 한정). 6. undo/redo 신설 — B05·B06 조작 세션 키 묶음 스냅샷(`_Profile_History.ts`). 버튼은 요약줄 맨 앞(최대 기울기 좌측), 21x17px. Ctrl+Z / Ctrl+Shift+Z. 7. 종단 요약줄의 횡단배수 최소고 표시 삭제(산식·편집 차단은 유지). 8. [편집 되돌리기] 버튼 삭제 — undo/redo로 대체. 9. 지형 구분 기본값 특수지형 — 패널 셀렉트와 백엔드 기본값(스키마·체인 폴백) 일치. 700줄 제한 — 패널을 `_Profile_Panel_Tools` · `_Profile_Preview` 로 분리하고 측점↔구조물 짝짓기를 `_Profile_Structures` 로 이관(842줄 → 696줄). 검증: tsc --noEmit 오류 0, ruff format/check 통과, pytest tmp/tests/ -q → 366 passed / 14 skipped / 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
125 lines
4.6 KiB
TypeScript
125 lines
4.6 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_UI_Profile_History.ts
|
|
* B05 조작 되돌리기(undo)·다시하기(redo) — 세션 캐시 묶음 스냅샷.
|
|
*
|
|
* 왜 세션 캐시 통째인가(2026-09-02 사용자 지시): B05의 조작값은 화면마다 흩어진 것이
|
|
* 아니라 **세션 저장소 한 벌**로 모인다(계획선 편집 초안 · 구조물 대기분 · 상단측
|
|
* 오버라이드 · 최신 경로 캐시). 종단 계획선을 건드리면 구조물 자리와 횡단 설계 캐시가
|
|
* 함께 바뀌므로, 종단 편집만 되돌리면 나머지가 어긋난 채 남는다. 그래서 되돌리기 단위는
|
|
* **B05 조작 전부**이고, 스냅샷도 세션 키 묶음으로 뜬다.
|
|
*
|
|
* 저장하지 않는 것: 패널 접힘·높이·표시 토글 같은 **화면 배치 값**. 조작 이력이 아니라
|
|
* 보기 설정이라 되돌릴 대상이 아니다(`isLayoutKey`).
|
|
* ========================================================================== */
|
|
|
|
/** 조작값 스냅샷 대상 세션 키 접두어. B05·B06은 한 페이지라 함께 뜬다(CLAUDE.md 5장). */
|
|
const DATA_KEY_PREFIXES = ["b05:", "b05-", "b06:", "b06-"];
|
|
|
|
/** 조작이 아니라 보기 설정인 키 — 스냅샷에서 뺀다. */
|
|
function isLayoutKey(key: string): boolean {
|
|
return (
|
|
key.includes("collapsed") ||
|
|
key.includes("width") ||
|
|
key.includes("height") ||
|
|
key.includes("open") ||
|
|
key.includes("visible")
|
|
);
|
|
}
|
|
|
|
function isDataKey(key: string): boolean {
|
|
return DATA_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)) && !isLayoutKey(key);
|
|
}
|
|
|
|
/** 한 시점의 조작값 — 키 → 값(JSON 문자열). */
|
|
export type HistorySnapshot = Record<string, string>;
|
|
|
|
function takeSnapshot(): HistorySnapshot {
|
|
const snapshot: HistorySnapshot = {};
|
|
try {
|
|
for (let index = 0; index < sessionStorage.length; index += 1) {
|
|
const key = sessionStorage.key(index);
|
|
if (!key || !isDataKey(key)) continue;
|
|
const value = sessionStorage.getItem(key);
|
|
if (value !== null) snapshot[key] = value;
|
|
}
|
|
} catch {
|
|
// 세션 저장소를 못 쓰는 환경에서는 되돌리기만 비활성이 된다.
|
|
}
|
|
return snapshot;
|
|
}
|
|
|
|
function sameSnapshot(left: HistorySnapshot, right: HistorySnapshot): boolean {
|
|
const leftKeys = Object.keys(left);
|
|
const rightKeys = Object.keys(right);
|
|
if (leftKeys.length !== rightKeys.length) return false;
|
|
return leftKeys.every((key) => left[key] === right[key]);
|
|
}
|
|
|
|
/** 스냅샷을 세션에 되쓴다 — 스냅샷에 없던 조작 키는 지운다. */
|
|
function restoreSnapshot(snapshot: HistorySnapshot): void {
|
|
try {
|
|
const existing: string[] = [];
|
|
for (let index = 0; index < sessionStorage.length; index += 1) {
|
|
const key = sessionStorage.key(index);
|
|
if (key && isDataKey(key)) existing.push(key);
|
|
}
|
|
existing.forEach((key) => {
|
|
if (!(key in snapshot)) sessionStorage.removeItem(key);
|
|
});
|
|
Object.entries(snapshot).forEach(([key, value]) => sessionStorage.setItem(key, value));
|
|
} catch {
|
|
// 되쓰기 실패 시 화면 상태는 그대로 둔다 — 잘못 섞인 복원보다 낫다.
|
|
}
|
|
}
|
|
|
|
export interface ProfileHistory {
|
|
/** 조작이 끝난 뒤 현재 상태를 이력에 쌓는다(직전과 같으면 무시). */
|
|
record(): void;
|
|
undo(): boolean;
|
|
redo(): boolean;
|
|
canUndo(): boolean;
|
|
canRedo(): boolean;
|
|
}
|
|
|
|
/**
|
|
* 되돌리기 이력을 만든다.
|
|
*
|
|
* `onRestore`는 세션 값이 바뀐 뒤 화면을 다시 세우는 콜백이다 — 세션이 정본이므로
|
|
* 여기서 각 패널이 자기 값을 다시 읽어 그린다.
|
|
*/
|
|
export function createProfileHistory(onRestore: () => void, limit = 50): ProfileHistory {
|
|
const stack: HistorySnapshot[] = [takeSnapshot()];
|
|
let cursor = 0;
|
|
/** 복원 중에 들어오는 record()를 무시한다 — 복원이 새 이력을 만들면 redo가 사라진다. */
|
|
let restoring = false;
|
|
|
|
function apply(index: number): boolean {
|
|
if (index < 0 || index >= stack.length) return false;
|
|
cursor = index;
|
|
restoring = true;
|
|
restoreSnapshot(stack[cursor]);
|
|
try {
|
|
onRestore();
|
|
} finally {
|
|
restoring = false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
return {
|
|
record() {
|
|
if (restoring) return;
|
|
const snapshot = takeSnapshot();
|
|
if (sameSnapshot(snapshot, stack[cursor])) return;
|
|
stack.splice(cursor + 1);
|
|
stack.push(snapshot);
|
|
if (stack.length > limit) stack.shift();
|
|
cursor = stack.length - 1;
|
|
},
|
|
undo: () => apply(cursor - 1),
|
|
redo: () => apply(cursor + 1),
|
|
canUndo: () => cursor > 0,
|
|
canRedo: () => cursor < stack.length - 1,
|
|
};
|
|
}
|