Files
Aislo/B05_Profile/B05_Profile_UI_RouteEdit_History.ts
T
eomsangdonandClaude Opus 5.5 84e9151b7c feat(B05 평면): 예정노선 그리기 — 새로 긋기 · 구간 다시 긋기 · 지움 → 평면선 다시 추적 = 편집기 한 걸음 → [저장] (PLAN 626-3 · 요구 530 · 531 · D3 · D4 · D9 · D10)
- 도구줄 「예정노선 그리기」 칸(공용 표 b05 · 연필 아이콘) → 떠 있는 판(단추 셋 + 공용 방식 칸) · 고치는 예정노선 맨 위 · 판 켠 동안 캔버스 누름을 먼저 받음(계획선 · 구조물 · 등고선 고르기 · Shift 재기 · 노드 넣기 · 지움 막음 · 가운데 끌기 · 휠 그대로)
- 긋기 = D03 과 같은 방식 칸 · 부채꼴 · +/− 색 · Shift 사이 기울기 · Tab · Enter · Esc · Backspace(셈 `logic/B05_Profile_ExpectedDraw` · 그림 D03 `paintPreview` 그대로)
- 끝 → 확인창(직접 고친 평면 버림) → `POST /route/follow`(저장 0) → `restoreSnapshot(…, true)` 한 걸음(되돌리기 됨) · 지움 = 파일입력 예정노선으로 다시 추적
- 걸음 사진 · 노선 초안에 `expected`(같은 선 판정 · 세션 되살림 · 페이지 되돌리기) · [저장] = 손 편집과 같은 길 + 초안 `expected`(null 안 실음 · [] 지움) · 다시 열면 `expected_edited`
- `/route/replan` · `as_initial` · `expected_route.csv` · 초기값 · 추천 저장본 안 건드림 · 시험 test_626_3_expected_edit(7)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 21:58:15 +09:00

191 lines
8.6 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_RouteEdit_History.ts
* 노선 편집의 **되돌리기·다시하기·초기화** (2026-09-07 사용자 지시).
*
* 왜 필요한가 — [확인]은 배수유역부터 다시 도는 무거운 작업이라 되돌릴 길이 없다. 그러니
* **창 안에서** 실수를 물릴 수 있어야 한다. 여기서 말하는 [초기화]는 **이 창을 연 상태**로
* 돌아가는 것이지, 예상노선으로 되돌리는 것(`[예상노선으로]`, 서버 재계산)이 아니다.
*
* 값을 통째로 사진처럼 담는다(델타 아님) — 노드·곡선 켬끔·반지름이 서로 엮여 있어 델타로
* 쪼개면 되돌릴 때 어긋나기 쉽다. 노선 하나가 노드 수십 개라 사진 몇 벌은 가볍다.
*
* 되돌리기 줄은 페이지 사진첩 하나(`_Profile_History` · 346-4) — 계산 묶음 다리가 있으면(`_Draft.watch`)
* [되돌리기] · [다시하기] · Ctrl+Z · Y 는 사진첩이 받고, 여기 사진은 고정 · 추천 꼬리표 · 고른 점을
* 되살리는 기록(`match`) · 고친 것 있음(`isDirty`)에만 씀.
* ========================================================================== */
import { onPageHistory } from "./B05_Profile_UI_Profile_History";
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>;
/** 교각점(꺾임점) 자리를 못 박았나 — 켜진 자리는 끌어도 안 움직인다(2026-09-12 지시 ①). */
apexLock: boolean[];
picked: number;
/** 고친 예정노선(626-3) — 없음 · null = 연 때 그대로 · [] = 지움(파일입력 예정노선) · 점 묶음 = 새로 그은 선. */
expected?: Vertex[] | null;
/** 이 걸음의 「현재 추천」 — `_Initial` 이 적고 되돌릴 때 되살린다(뜻은 그쪽만 안다). 노선과 짝. */
rec?: unknown;
}
/** 쌓아 둘 사진 수 상한 — 넘으면 오래된 것부터 버린다. */
const MAX_STEPS = 100;
export interface RouteEditHistory {
/** 편집 한 번이 끝났다 — 지금 상태를 사진으로 쌓는다(다시하기 갈래는 버린다). */
commit: (snapshot: RouteEditSnapshot) => void;
/** 한 걸음 뒤로. 되돌릴 것이 없으면 null. */
undo: () => RouteEditSnapshot | null;
/** 한 걸음 앞으로. 없으면 null. */
redo: () => RouteEditSnapshot | null;
canUndo: () => boolean;
canRedo: () => boolean;
/** 창을 연 뒤로 고친 것이 있나 — [초기화]를 켤지 정한다. */
isDirty: () => boolean;
/** 창을 연 상태 · 지금 걸음(사본). */
first?: () => RouteEditSnapshot;
current?: () => RouteEditSnapshot;
/** 같은 노선(노드 · 곡선)의 가장 최근 걸음 — 고정 · 추천 꼬리표 · 고른 점을 되살림. 없으면 null. */
match?: (snapshot: RouteEditSnapshot) => RouteEditSnapshot | null;
/** 페이지 사진첩이 되쓴 노선 초안을 읽어 새 걸음으로 — 노선이 그대로면 null(`_Draft.watch`). */
fromPage?: () => RouteEditSnapshot | null;
}
/** 사진을 깊이 복사한다 — 배열을 그대로 담으면 뒤이은 편집이 과거까지 바꾼다. */
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],
apexLock: [...snapshot.apexLock],
picked: snapshot.picked,
rec: snapshot.rec,
expected: snapshot.expected?.map(([x, y]): Vertex => [x, y]) ?? null,
};
}
/** 고친 예정노선이 같은가(626-3 · 없음 = null). */
const sameExpected = (a: RouteEditSnapshot, b: RouteEditSnapshot): boolean =>
JSON.stringify(a.expected ?? null) === JSON.stringify(b.expected ?? null);
/** 두 사진이 같은 선(노드 · 곡선)인가 — 고정 · 고른 자리는 안 본다. */
export function sameLine(a: RouteEditSnapshot, b: RouteEditSnapshot): boolean {
if (a.planned.length !== b.planned.length || !sameExpected(a, b)) 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;
}
/** 노선(노드 · 곡선)이 창을 연 때와 다른가 — 고정만 바꾼 것은 노선 저장 거리가 아님(445-2 · [초기화]는 `isDirty`). */
export const lineChanged = (history: RouteEditHistory): boolean =>
!!history.first && !!history.current && !sameLine(history.current(), history.first());
/** 두 사진이 **같은 노선**인가 — [초기화]를 켤지 정할 때 쓴다. 고른 자리는 안 본다. */
function sameRoute(a: RouteEditSnapshot, b: RouteEditSnapshot): boolean {
return sameLine(a, b) && a.apexLock.every((lock, index) => lock === b.apexLock[index]);
}
/** 첫 사진(창을 연 상태)으로 이력을 연다. */
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]);
},
canUndo: () => at > 0,
canRedo: () => at < steps.length - 1,
isDirty: () => !sameRoute(steps[at], steps[0]),
first: () => clone(steps[0]),
current: () => clone(steps[at]),
match(snapshot) {
for (let index = steps.length - 1; index >= 0; index -= 1) {
if (sameLine(steps[index], snapshot)) return clone(steps[index]);
}
return null;
},
};
}
export interface HistoryControlsParams {
/** 단추가 들어 있는 칸 — `[data-act]` 로 찾는다. */
overlay: HTMLElement;
/** 아직 노선을 못 읽었으면 null 이다(단추는 꺼진 채로 둔다). */
getHistory: () => RouteEditHistory | null;
/** 사진 한 벌을 화면에 되살린다. */
restore: (snapshot: RouteEditSnapshot, message: string) => void;
}
/** [되돌리기]·[다시하기] 단추를 붙인다 — Ctrl+Z · Y 는 페이지 사진첩 하나가 받음(`_Profile_Panel_Tools`).
*
* 돌려주는 `sync` 를 편집이 끝날 때마다 부르면 단추 켜짐이 맞춰진다(사진첩이 바뀔 때도 스스로).
* 사진첩이 되돌린 걸음에 노선이 바뀌었으면 `restore` 로 편집기에 되살림. `listen` 은 옛 자리(보기 보임 ·
* 숨김)를 위해 남김 — 단축키를 안 들으므로 할 일 없음. */
export function bindHistoryControls(params: HistoryControlsParams): {
sync: () => void;
listen: (on: boolean) => 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 sync = (): void => {
const history = getHistory();
undoBtn.disabled = !history?.canUndo();
redoBtn.disabled = !history?.canRedo();
};
const step = (which: "undo" | "redo"): void => {
const history = getHistory();
if (!history) return;
const snapshot = history[which]();
if (snapshot) restore(snapshot, which === "undo" ? "되돌렸습니다." : "다시 했습니다.");
sync();
};
undoBtn.addEventListener("click", () => step("undo"));
redoBtn.addEventListener("click", () => step("redo"));
const off = onPageHistory({
restore: (kind) => {
const snapshot = getHistory()?.fromPage?.();
if (snapshot) restore(snapshot, kind === "undo" ? "되돌렸습니다." : "다시 했습니다.");
},
change: sync,
});
return { sync, listen: () => undefined, dispose: off };
}