/* ============================================================================= * B05_Profile_UI_RouteEdit_History.ts * 노선 편집의 **되돌리기·다시하기·초기화** (2026-09-07 사용자 지시). * * 왜 필요한가 — [확인]은 배수유역부터 다시 도는 무거운 작업이라 되돌릴 길이 없다. 그러니 * **창 안에서** 실수를 물릴 수 있어야 한다. 여기서 말하는 [초기화]는 **이 창을 연 상태**로 * 돌아가는 것이지, 예상노선으로 되돌리는 것(`[예상노선으로]`, 서버 재계산)이 아니다. * * 값을 통째로 사진처럼 담는다(델타 아님) — 노드·곡선 켬끔·반지름이 서로 엮여 있어 델타로 * 쪼개면 되돌릴 때 어긋나기 쉽다. 노선 하나가 노드 수십 개라 사진 몇 벌은 가볍다. * ========================================================================== */ export type Vertex = [number, number]; /** 되돌릴 수 있는 편집 상태 한 벌. */ export interface RouteEditSnapshot { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array; /** 무엇을 붙들고 있나 — 반지름 | 곡선 길이 | 없음 (`_Edits.ts`). */ curveLock: Array<"radius" | "arc" | null>; /** 길이를 붙들었을 때의 그 길이(m). */ curveArc: Array; picked: number; } /** 쌓아 둘 사진 수 상한 — 넘으면 오래된 것부터 버린다. */ const MAX_STEPS = 100; export interface RouteEditHistory { /** 편집 한 번이 끝났다 — 지금 상태를 사진으로 쌓는다(다시하기 갈래는 버린다). */ commit: (snapshot: RouteEditSnapshot) => void; /** 한 걸음 뒤로. 되돌릴 것이 없으면 null. */ undo: () => RouteEditSnapshot | null; /** 한 걸음 앞으로. 없으면 null. */ redo: () => RouteEditSnapshot | null; /** 창을 연 상태로. 이미 그 상태면 null. */ reset: () => RouteEditSnapshot | null; canUndo: () => boolean; canRedo: () => boolean; /** 창을 연 뒤로 고친 것이 있나 — [초기화]를 켤지 정한다. */ isDirty: () => boolean; } /** 사진을 깊이 복사한다 — 배열을 그대로 담으면 뒤이은 편집이 과거까지 바꾼다. */ function clone(snapshot: RouteEditSnapshot): RouteEditSnapshot { return { planned: snapshot.planned.map(([x, y]): Vertex => [x, y]), curveOn: [...snapshot.curveOn], curveRadius: [...snapshot.curveRadius], curveLock: [...snapshot.curveLock], curveArc: [...snapshot.curveArc], picked: snapshot.picked, }; } /** 두 사진이 **같은 노선**인가 — [초기화]를 켤지 정할 때 쓴다. 고른 자리는 안 본다. */ function sameRoute(a: RouteEditSnapshot, b: RouteEditSnapshot): boolean { if (a.planned.length !== b.planned.length) return false; for (let index = 0; index < a.planned.length; index += 1) { if (a.planned[index][0] !== b.planned[index][0]) return false; if (a.planned[index][1] !== b.planned[index][1]) return false; if (a.curveOn[index] !== b.curveOn[index]) return false; if (a.curveRadius[index] !== b.curveRadius[index]) return false; if (a.curveLock[index] !== b.curveLock[index]) return false; if (a.curveArc[index] !== b.curveArc[index]) return false; } return true; } /** 첫 사진(창을 연 상태)으로 이력을 연다. */ export function createRouteEditHistory(initial: RouteEditSnapshot): RouteEditHistory { const steps: RouteEditSnapshot[] = [clone(initial)]; let at = 0; return { commit(snapshot) { // 되돌린 뒤 새로 고치면 앞쪽 갈래는 버린다 — 흔한 되돌리기 규칙 그대로. steps.length = at + 1; steps.push(clone(snapshot)); if (steps.length > MAX_STEPS) steps.shift(); at = steps.length - 1; }, undo() { if (at <= 0) return null; at -= 1; return clone(steps[at]); }, redo() { if (at >= steps.length - 1) return null; at += 1; return clone(steps[at]); }, reset() { // 이미 연 상태 그대로면 할 일이 없다 — 눌러도 걸음만 늘어난다. if (sameRoute(steps[at], steps[0])) return null; // 초기화도 **되돌릴 수 있어야** 한다 — 첫 사진을 새 걸음으로 쌓는다. steps.length = at + 1; steps.push(clone(steps[0])); at = steps.length - 1; return clone(steps[at]); }, canUndo: () => at > 0, canRedo: () => at < steps.length - 1, isDirty: () => !sameRoute(steps[at], steps[0]), }; } export interface HistoryControlsParams { /** 단추가 들어 있는 칸 — `[data-act]` 로 찾는다. */ overlay: HTMLElement; /** 아직 노선을 못 읽었으면 null 이다(단추는 꺼진 채로 둔다). */ getHistory: () => RouteEditHistory | null; /** 사진 한 벌을 화면에 되살린다. */ restore: (snapshot: RouteEditSnapshot, message: string) => void; } /** [초기화]·[되돌리기]·[다시하기] 단추와 단축키(Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z)를 붙인다. * * 돌려주는 `sync` 를 편집이 끝날 때마다 부르면 단추 켜짐이 맞춰진다. `dispose` 는 창을 * 닫을 때 부른다 — 단축키를 창(window)에 달았기 때문에 안 떼면 닫힌 뒤에도 살아 있다. */ export function bindHistoryControls(params: HistoryControlsParams): { sync: () => void; dispose: () => void; } { const { overlay, getHistory, restore } = params; const undoBtn = overlay.querySelector('[data-act="undo"]')!; const redoBtn = overlay.querySelector('[data-act="redo"]')!; const resetBtn = overlay.querySelector('[data-act="history-reset"]')!; const sync = (): void => { const history = getHistory(); undoBtn.disabled = !history?.canUndo(); redoBtn.disabled = !history?.canRedo(); resetBtn.disabled = !history?.isDirty(); }; const step = (which: "undo" | "redo" | "reset"): void => { const history = getHistory(); if (!history) return; const snapshot = history[which](); if (!snapshot) return; restore( snapshot, which === "undo" ? "되돌렸습니다." : which === "redo" ? "다시 했습니다." : "창을 연 상태로 돌렸습니다.", ); sync(); }; undoBtn.addEventListener("click", () => step("undo")); redoBtn.addEventListener("click", () => step("redo")); resetBtn.addEventListener("click", () => step("reset")); const onKey = (event: KeyboardEvent): void => { if (!(event.ctrlKey || event.metaKey)) return; const key = event.key.toLowerCase(); if (key === "z" && !event.shiftKey) step("undo"); else if (key === "y" || (key === "z" && event.shiftKey)) step("redo"); else return; event.preventDefault(); }; window.addEventListener("keydown", onKey); return { sync, dispose: () => window.removeEventListener("keydown", onKey) }; }