사용자 지시 5건. ⚠ **화면 검증은 아직 못 함** — 다른 창의 분배 조율로 git sync 를 먼저 하게 되어 미커밋으로 두지 않으려고 먼저 커밋함. 타입 검사·시험은 통과 상태임. - **1. 패널을 화면으로** — `document.body` 에 `position: fixed` 로 띄움. 모달이 `overflow: hidden` 이라 안에 두면 가장자리에서 잘렸음. 이제 모달 밖으로도 넘어감. - **2. 너무 가까움 + 위치 제어** — 여백 14 → 40px 로 넓히고 방위별 상자 반지름만큼 더 밀어냄. **머리를 잡아 손으로 옮길 수 있음**. 옮긴 자리는 꺾임점 기준으로 기억해 지도를 옮기거나 확대해도 따라오고, 다른 꺾임점을 고르면 자동 자리로 돌아감. - **3. 자동 위치를 16방위로** — 4방위는 대각 자리에서 곡선을 물었음. 곡선 중심의 반대 방향을 22.5° 단위로 맞춤. - **4. 「법정 하한」 삭제**, 「칸을 비우면 자동」을 **다음 줄**로 뺌. 대신 무엇을 붙들고 있는지(고정 없음 / 반지름 고정 / 곡선 길이 고정)를 그 줄에 냄. - **5. 반지름·곡선 길이 고정 단추** — 노드를 옮기면 교각이 바뀌어 R 과 길이 중 하나는 반드시 따라 움직임. 그래서 **셋 중 하나**임: 자동 / R 고정(길이가 따라감) / 길이 고정(R 을 L/Δ 로 다시 잡음). 붙들어 둔 곡선은 **손잡이로도 안 바뀜**(끌면 R 이 바뀌므로 집기에서 제외). 잠금·길이는 되돌리기 사진에도 담김. - 새 모듈 `_Edits.ts` — 잠금 반영·상태줄 요약·서버 응답 펴기. 본체 692줄로 700줄 유지. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
170 lines
6.8 KiB
TypeScript
170 lines
6.8 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_UI_RouteEdit_History.ts
|
|
* 노선 편집의 **되돌리기·다시하기·초기화** (2026-09-07 사용자 지시).
|
|
*
|
|
* 왜 필요한가 — [확인]은 배수유역부터 다시 도는 무거운 작업이라 되돌릴 길이 없다. 그러니
|
|
* **창 안에서** 실수를 물릴 수 있어야 한다. 여기서 말하는 [초기화]는 **이 창을 연 상태**로
|
|
* 돌아가는 것이지, 예상노선으로 되돌리는 것(`[예상노선으로]`, 서버 재계산)이 아니다.
|
|
*
|
|
* 값을 통째로 사진처럼 담는다(델타 아님) — 노드·곡선 켬끔·반지름이 서로 엮여 있어 델타로
|
|
* 쪼개면 되돌릴 때 어긋나기 쉽다. 노선 하나가 노드 수십 개라 사진 몇 벌은 가볍다.
|
|
* ========================================================================== */
|
|
|
|
export type Vertex = [number, number];
|
|
|
|
/** 되돌릴 수 있는 편집 상태 한 벌. */
|
|
export interface RouteEditSnapshot {
|
|
planned: Vertex[];
|
|
curveOn: boolean[];
|
|
curveRadius: Array<number | null>;
|
|
/** 무엇을 붙들고 있나 — 반지름 | 곡선 길이 | 없음 (`_Edits.ts`). */
|
|
curveLock: Array<"radius" | "arc" | null>;
|
|
/** 길이를 붙들었을 때의 그 길이(m). */
|
|
curveArc: Array<number | null>;
|
|
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<HTMLButtonElement>('[data-act="undo"]')!;
|
|
const redoBtn = overlay.querySelector<HTMLButtonElement>('[data-act="redo"]')!;
|
|
const resetBtn = overlay.querySelector<HTMLButtonElement>('[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) };
|
|
}
|