diff --git a/B05_Profile/B05_Profile_UI_Profile_History.ts b/B05_Profile/B05_Profile_UI_Profile_History.ts index f5c35b334..92f3736fb 100644 --- a/B05_Profile/B05_Profile_UI_Profile_History.ts +++ b/B05_Profile/B05_Profile_UI_Profile_History.ts @@ -10,10 +10,15 @@ * * 저장하지 않는 것: 패널 접힘·높이·표시 토글 같은 **화면 배치 값**. 조작 이력이 아니라 * 보기 설정이라 되돌릴 대상이 아니다(`isLayoutKey`). + * + * 대상 = 초안 통(`aislo:draft:` 전부 — 노선 초안 · 계획고 · 관 · 구조물 · 횡단) + 옛 b05 · b06 키(346-4). + * 끌기 · 누르고 있기 중간은 안 찍음 — 누름이 남아 있는 동안 record() 는 미루고 놓을 때 한 번. + * 사진끼리 안 바뀐 값은 앞 사진 글을 같이 씀 — 칸마다 수백 KB × 100단이어도 바뀐 칸만 늘어남. * ========================================================================== */ /** 조작값 스냅샷 대상 세션 키 접두어. B05·B06은 한 페이지라 함께 뜬다(CLAUDE.md 5장). */ -const DATA_KEY_PREFIXES = ["b05:", "b05-", "b06:", "b06-", "aislo:draft:alignment-draft:"]; +const DRAFT_PREFIX = "aislo:draft:"; +const LEGACY_KEY_PREFIXES = ["b05:", "b05-", "b06:", "b06-"]; /** 조작이 아니라 보기 설정인 키 — 스냅샷에서 뺀다. */ function isLayoutKey(key: string): boolean { @@ -27,20 +32,22 @@ function isLayoutKey(key: string): boolean { } function isDataKey(key: string): boolean { - return DATA_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)) && !isLayoutKey(key); + if (key.startsWith(DRAFT_PREFIX)) return true; + return LEGACY_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)) && !isLayoutKey(key); } /** 한 시점의 조작값 — 키 → 값(JSON 문자열). */ export type HistorySnapshot = Record; -function takeSnapshot(): HistorySnapshot { +/** `previous` 와 같은 값은 그 글을 그대로 담음(메모리 한 벌). */ +function takeSnapshot(previous?: HistorySnapshot): 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; + if (value !== null) snapshot[key] = previous?.[key] === value ? previous[key] : value; } } catch { // 세션 저장소를 못 쓰는 환경에서는 되돌리기만 비활성이 된다. @@ -55,6 +62,39 @@ function sameSnapshot(left: HistorySnapshot, right: HistorySnapshot): boolean { return leftKeys.every((key) => left[key] === right[key]); } +/** 누름(마우스 · 터치 · 펜)이 남아 있나 — 창 하나에 한 번 듣고 놓으면 미룬 기록을 부름. */ +let pointerHeld = false; +const releaseWaiters = new Set<() => void>(); +let pointerWatched = false; + +function watchPointer(): void { + if (pointerWatched || typeof window === "undefined") return; + if (typeof window.addEventListener !== "function") return; + pointerWatched = true; + window.addEventListener("pointerdown", () => (pointerHeld = true), true); + const release = (): void => { + if (!pointerHeld) return; + // 놓을 때 부르는 쪽(끌기 마무리 · 세션 쓰기)이 먼저 끝나게 한 틱 뒤. + window.setTimeout(() => { + pointerHeld = false; + const waiters = [...releaseWaiters]; + releaseWaiters.clear(); + waiters.forEach((fn) => fn()); + }, 0); + }; + window.addEventListener("pointerup", release, true); + window.addEventListener("pointercancel", release, true); + window.addEventListener("blur", release); + // 창 밖에서 놓아 pointerup 을 못 받은 때 — 단추 없이 움직이면 놓은 것. + window.addEventListener( + "pointermove", + (event) => { + if (pointerHeld && event.buttons === 0) release(); + }, + true, + ); +} + /** 스냅샷을 세션에 되쓴다 — 스냅샷에 없던 조작 키는 지운다. */ function restoreSnapshot(snapshot: HistorySnapshot): void { try { @@ -91,7 +131,8 @@ export interface ProfileHistory { * `onRestore`는 세션 값이 바뀐 뒤 화면을 다시 세우는 콜백이다 — 세션이 정본이므로 * 여기서 각 패널이 자기 값을 다시 읽어 그린다. */ -export function createProfileHistory(onRestore: () => void, limit = 50): ProfileHistory { +export function createProfileHistory(onRestore: () => void, limit = 100): ProfileHistory { + watchPointer(); const stack: HistorySnapshot[] = [takeSnapshot()]; let cursor = 0; /** 복원 중에 들어오는 record()를 무시한다 — 복원이 새 이력을 만들면 redo가 사라진다. */ @@ -112,10 +153,14 @@ export function createProfileHistory(onRestore: () => void, limit = 50): Profile return true; } - return { + const history: ProfileHistory = { record() { if (restoring || paused) return; - const snapshot = takeSnapshot(); + if (pointerHeld) { + releaseWaiters.add(history.record); + return; + } + const snapshot = takeSnapshot(stack[cursor]); if (sameSnapshot(snapshot, stack[cursor])) return; stack.splice(cursor + 1); stack.push(snapshot); @@ -128,11 +173,12 @@ export function createProfileHistory(onRestore: () => void, limit = 50): Profile resume() { if (!paused) return; paused = false; - this.record(); + history.record(); }, undo: () => apply(cursor - 1), redo: () => apply(cursor + 1), canUndo: () => cursor > 0, canRedo: () => cursor < stack.length - 1, }; + return history; } diff --git a/B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts b/B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts index 47ec5b627..b9e3d0d88 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts @@ -13,6 +13,7 @@ * ========================================================================== */ import type { + DesignCurve, GroundOutput, GroundSample, PipelineStation, @@ -38,35 +39,43 @@ import { export interface RouteStageDeps { /** 곡선 기본 반지름(m) — 편집기 `minRadiusM`(계획 기준 · 0 = 없음). */ minRadiusM(): number; - /** 초안이 없을 때(= 저장분 그대로) 그릴 노선 — 없으면 null. */ - savedLine(): Vertex[] | null; + /** 초안이 없을 때(= 저장분 그대로) 그릴 노선 · 곡선표 — 없으면 null. */ + savedLine(): { line: Vertex[]; curves: DesignCurve[] } | null; } /** 정점마다 누가거리 — 첫 점 0. */ -export function routeLineOf(line: Vertex[]): RouteLine { +export function routeLineOf(line: Vertex[], curves: DesignCurve[] = []): RouteLine { const chainage = [0]; for (let i = 1; i < line.length; i += 1) { chainage.push( chainage[i - 1] + Math.hypot(line[i][0] - line[i - 1][0], line[i][1] - line[i - 1][1]), ); } - return { line, chainage, lengthM: chainage[chainage.length - 1] }; + return { line, curves, chainage, lengthM: chainage[chainage.length - 1] }; } -/** 초안 → 그린 노선. 길이를 붙든 곡선은 교각에 맞춰 R 을 다시 잡음(편집기 `markEdited` 와 같은 차례). */ -export function draftLine(draft: RouteDraft, minRadiusM: number): Vertex[] { +/** 초안 → 그린 노선 · 곡선표(계약 꼴). 길이를 붙든 곡선은 교각에 맞춰 R 을 다시 잡음(편집기 + * `markEdited` 와 같은 차례). */ +export function draftLine( + draft: RouteDraft, + minRadiusM: number, +): { line: Vertex[]; curves: DesignCurve[] } { const nodes = draft.nodes.map((node): Vertex => [node[0], node[1]]); const radius = [...draft.curveRadius]; applyArcLocks(nodes, [...draft.curveLock], [...draft.curveArc], radius); - return buildEditedPolyline(nodes, draft.curveOn, radius, minRadiusM).vertices; + const built = buildEditedPolyline(nodes, draft.curveOn, radius, minRadiusM); + return { + line: built.vertices, + curves: built.curves.map(({ start, apex, end, radius_m }) => ({ start, apex, end, radius_m })), + }; } export function createRouteStage(deps: RouteStageDeps): StageFn<"route"> { return (ctx) => { const draft = ctx.inputs.route; - const line = draft ? draftLine(draft, deps.minRadiusM()) : deps.savedLine(); - if (!line || line.length < 2) throw new Error("그릴 노선이 없습니다."); - return routeLineOf(line); + const drawn = draft ? draftLine(draft, deps.minRadiusM()) : deps.savedLine(); + if (!drawn || drawn.line.length < 2) throw new Error("그릴 노선이 없습니다."); + return routeLineOf(drawn.line, drawn.curves); }; } diff --git a/resources/tester/test_346_1_route_ground_stage.py b/resources/tester/test_346_1_route_ground_stage.py index 9f25a8298..0c2c3f8e2 100644 --- a/resources/tester/test_346_1_route_ground_stage.py +++ b/resources/tester/test_346_1_route_ground_stage.py @@ -49,7 +49,9 @@ const draft = { rev: 1, nodes: job.nodes, curveOn: Array(n).fill(true), curveRadius: Array(n).fill(null), curveLock: Array(n).fill(null), curveArc: Array(n).fill(null), stationSnap: false, terrain: null, }; -const routeStage = createRouteStage({ minRadiusM: () => job.minRadius, savedLine: () => job.line }); +const routeStage = createRouteStage({ + minRadiusM: () => job.minRadius, savedLine: () => ({ line: job.line, curves: [] }), +}); const ctxOf = (extra) => ({ generation: 1, range: null, signal: new AbortController().signal, inputs: { route: null, alignment: null, cross: null, pipes: null, structures: null }, @@ -242,7 +244,14 @@ def test_route_ground_단계가_서버와_같고_범위_밖을_재사용한다(t steps = np.hypot(*np.diff(np.asarray(built.vertices), axis=0).T) assert abs(route["lengthM"] - float(steps.sum())) < 1e-6 assert route["chainage"][0] == 0 and len(route["chainage"]) == len(route["line"]) + assert len(route["curves"]) == len(built.curves) > 0 + for ts, py in zip(route["curves"], built.curves, strict=True): + assert set(ts) == {"start", "apex", "end", "radius_m"} + assert abs(ts["radius_m"] - py.radius_m) < 1e-9 + for key in ("start", "apex", "end"): + assert max(abs(a - b) for a, b in zip(ts[key], getattr(py, key))) < 1e-9 assert out["saved"]["line"] == line + assert out["saved"]["curves"] == [] assert out["noRoute"] # ground 브라우저 길 = 서버 generate_sections(측점 · 횡단 · 종단) diff --git a/resources/tester/test_346_4_profile_history.py b/resources/tester/test_346_4_profile_history.py new file mode 100644 index 000000000..57569eccf --- /dev/null +++ b/resources/tester/test_346_4_profile_history.py @@ -0,0 +1,128 @@ +"""346-4 되돌리기 앞일 — 사진 대상 = aislo:draft: 전부 · 누름 중간은 안 찍고 놓을 때 한 번 · 크기. + +./venv/Scripts/python.exe -m pytest resources/tester/test_346_4_profile_history.py -q +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +TS_FILE = ROOT / "B05_Profile" / "B05_Profile_UI_Profile_History.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + +DRIVER = r""" +import { writeFileSync } from "node:fs"; +// 세션 흉내 — getItem 은 진짜처럼 매번 새 글을 돌려줌(사진끼리 글을 같이 쓰는지 재려고). +const data = new Map(); +globalThis.sessionStorage = { + get length() { return data.size; }, + key: (i) => [...data.keys()][i] ?? null, + getItem: (k) => (data.has(k) ? Buffer.from(data.get(k)).toString() : null), + setItem: (k, v) => data.set(k, String(v)), + removeItem: (k) => data.delete(k), +}; +globalThis.window = new EventTarget(); +window.setTimeout = setTimeout; +const { createProfileHistory } = await import("./B05_Profile_UI_Profile_History.js"); +const tick = () => new Promise((r) => setTimeout(r, 5)); +const out = {}; + +// 1. 대상 — 초안 통 전부 + 옛 키 · 보기 설정 · pref · result 는 뺌 +const K = { + route: "aislo:draft:route-draft:p1", + cross: "aislo:draft:crossdesign:p1:7", + pipes: "aislo:draft:pipes:p1", + legacy: "b05:uphill:p1", + layout: "b05-panel-collapsed", + pref: "aislo:pref:table-open", + result: "aislo:result:latest:p1", +}; +for (const k of Object.values(K)) sessionStorage.setItem(k, "0"); +let restored = 0; +const h = createProfileHistory(() => (restored += 1)); +for (const k of Object.values(K)) sessionStorage.setItem(k, "1"); +h.record(); +out.undo = h.undo(); +out.after = Object.fromEntries(Object.entries(K).map(([n, k]) => [n, sessionStorage.getItem(k)])); +out.restored = restored; + +// 2. 누르고 있는 동안 기록 미룸 · 놓으면 한 번 +data.clear(); +sessionStorage.setItem(K.cross, "a"); +const h2 = createProfileHistory(() => {}); +window.dispatchEvent(new Event("pointerdown")); +for (const v of ["b", "c", "d", "e"]) { + sessionStorage.setItem(K.cross, v); + h2.record(); +} +out.heldCanUndo = h2.canUndo(); +window.dispatchEvent(new Event("pointerup")); +await tick(); +out.releasedCanUndo = h2.canUndo(); +h2.undo(); +out.oneUndo = sessionStorage.getItem(K.cross); +out.noMoreUndo = !h2.canUndo(); + +// 3. 크기 — 300 KB 칸 셋 · 작은 칸만 바꾸며 100단 +data.clear(); +const big = (c) => c.repeat(300 * 1024); +sessionStorage.setItem(K.route, big("r")); +sessionStorage.setItem(K.cross, big("x")); +sessionStorage.setItem(K.pipes, big("p")); +globalThis.gc(); +const before = process.memoryUsage().heapUsed; +const h3 = createProfileHistory(() => {}); +for (let i = 0; i < 100; i += 1) { + sessionStorage.setItem("aislo:draft:alignment-draft:p1:7", JSON.stringify({ i })); + h3.record(); +} +globalThis.gc(); +out.heapMb = (process.memoryUsage().heapUsed - before) / 1024 / 1024; +let steps = 0; +while (h3.undo()) steps += 1; +out.steps = steps; +writeFileSync(process.argv[2], JSON.stringify(out)); +""" + + +@pytest.fixture(scope="module") +def result(tmp_path_factory: pytest.TempPathFactory) -> dict: + out = tmp_path_factory.mktemp("ts_history") + subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--module", "esnext", + "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig", "--noCheck", + "--noResolve"], + cwd=str(ROOT), check=True, capture_output=True, + ) # fmt: skip + (out / "driver.mjs").write_text(DRIVER, encoding="utf-8") + run = subprocess.run( # noqa: S603 + ["node", "--expose-gc", str(out / "driver.mjs"), str(out / "out.json")], + capture_output=True, text=True, cwd=str(out), + ) # fmt: skip + assert run.returncode == 0, run.stderr + return json.loads((out / "out.json").read_text(encoding="utf-8")) + + +def test_초안_통_전부를_되돌림(result) -> None: + after = result["after"] + assert result["undo"] and result["restored"] == 1 + assert after["route"] == after["cross"] == after["pipes"] == after["legacy"] == "0" + # 보기 설정 · pref · result 는 사진 밖 — 되돌리기가 안 건드림 + assert after["layout"] == after["pref"] == after["result"] == "1" + + +def test_누름_중간은_안_찍고_놓을_때_한_번(result) -> None: + assert result["heldCanUndo"] is False + assert result["releasedCanUndo"] is True + assert result["oneUndo"] == "a" and result["noMoreUndo"] + + +def test_큰_칸_100단도_바뀐_칸만_늚(result) -> None: + assert result["steps"] == 99 # 한도 100(첫 사진 포함) + # 같은 글을 같이 쓰지 않으면 900 KB × 100 ≈ 90 MB(UTF-8 1바이트 글 기준 · 실제는 더) + assert result["heapMb"] < 10, result["heapMb"]