fix(472 덧 B05 관 지움 ↶): 관 초안 없는 걸음 = 저장분 — 판이 마지막 저장분 답과 견줘 다르면 조용히 다시 읽음 · 옛 「처음 본 관 목록」(판이 읽기 전 빈 목록 · 관 하나 지움 ↶ 이 같은 목록으로 멈춤 → 배수판 · 시설 줄 · 알약 · 목록 관 없음 · −17,385.4 · B06 보기 배수유역도 관 · 세부유역 빠짐) 걷음 · 종단 ↶ ↷ 켜짐 = 사진첩 change 알림으로 바로(삭제 직후 꺼져 있던 흠) · 시험 test_472_d_b05_pipe_undo(옛 코드 빨강 2) · 옛 단언 215_1 이름 · 219_1 흉내 · 위키 operations_common · _b05 · rules 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 16:45:44 +09:00
co-authored by Claude Opus 5.5
parent b14de92621
commit be9d1b9185
12 changed files with 261 additions and 42 deletions
+11 -1
View File
@@ -333,6 +333,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
const fordFilled = fillFordHeights(response.pipe_points, response.basins, fordWaits());
facilityStore.replaceFromResponse(response.pipe_points);
if (fordFilled) rememberPipes();
if (saved?.response === response) saved.request = JSON.stringify(pipeRequest());
// 재계산 뒤에는 선택을 다시 알려야 한다 — 관을 새로 넣은 직후에는 목록이 아직
// 비어 폼이 열리지 못했고, 중복 억제 가드에 걸려 두 번째 기회를 잃는다.
lastNotifiedPipeChainage = null;
@@ -368,6 +369,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
})),
);
/** 마지막 저장분 답 · 그 답을 실은 뒤 요청 몸통 — 초안 없는 걸음으로 되돌릴 때 견줌(472 덧). */
let saved: { response: DetailBasinResponse; request: string | null } | null = null;
/** 지금 화면의 관 목록을 세션 초안에 담는다 — 정본 쓰기는 [저장]·[확정] 몫이다. */
function rememberPipes(): void {
if (!projectId) return;
@@ -380,7 +384,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
const pending = readPendingPipes(projectId as string);
return pending !== null // 빈 목록 = 다 지움(관 0개 · 312) — 저장분으로 안 넘어감
? computeDetailBasins(projectId as string, pending)
: fetchDetailPipePoints(projectId as string);
: fetchDetailPipePoints(projectId as string).then(
(response) => ((saved = { response, request: null }), response),
);
};
/** 세부유역을 다시 나눈다(격자 해석 없음) — 같은 틱의 여러 부름은 한 번(342-3 · 옛 소수 관 옮김).
@@ -529,6 +535,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
root,
load(nextProjectId: string) {
if (projectId === nextProjectId && meta) return;
if (projectId !== nextProjectId) saved = null;
projectId = nextProjectId;
void loadLayers();
},
@@ -563,6 +570,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
selectBasin(picked ? picked.index : null, false);
},
setPipeChainages(chainages) {
// 초안 없는 걸음 = 저장분 — 마지막 저장분 그대로면 끝 · 아니면 조용히 다시 읽음(472 덧)
if (chainages === null)
return void (JSON.stringify(pipeRequest()) !== saved?.request && run(loadSaved, true));
const before = pipeEditor.pipes().map((pipe) => pipe.chainage_m);
const next = reconcilePipes(pipeEditor.pipes(), chainages);
if (!next) return; // 같은 목록 — 되돌아온 것이므로 여기서 끊는다
@@ -37,8 +37,9 @@ export interface DrainagePanel {
/** 종단 테이블에서 배관 라인을 끌었을 때 — 그 자리로 옮기고 세부유역을 다시 나눈다. */
movePipe: (fromChainage: number, toChainage: number) => void;
/** 관 목록을 통째로 맞춘다(사이드바 구조물 폼 편집 등 밖에서 바뀐 경우).
* 현재 목록과 같으면 아무 일도 하지 않는다 — 되먹임 고리를 끊는 지점이다. */
setPipeChainages: (chainages: ReadonlyArray<number>) => void;
* 현재 목록과 같으면 아무 일도 하지 않는다 — 되먹임 고리를 끊는 지점이다.
* null = 초안 없는 걸음(되돌리기) — 저장분으로(472 덧). */
setPipeChainages: (chainages: ReadonlyArray<number> | null) => void;
/** 종단 테이블 우클릭·통합 목록에서 계곡 통과 시설을 넣을 때.
* attributes에 시설 종류·구간·부속 옵션이 담긴다(생략 = 맨 배관). */
addPipe: (chainageM: number, attributes?: FacilityAttributes) => void;
@@ -20,6 +20,7 @@ import { adjustStation } from "./logic/B05_Profile_UI_Profile_Alignment";
import { HOLD_DELAY_MS, HOLD_INTERVAL_MS } from "./B05_Profile_UI_Profile_Edit";
import {
createProfileHistory,
onPageHistory,
setPageHistory,
type ProfileHistory,
} from "./B05_Profile_UI_Profile_History";
@@ -63,8 +64,7 @@ export interface PanelToolsContext {
moveStation: (station: IrregularStation, toChainageM: number) => void;
/** 관 목록 정본 — 되돌리기가 구조물 위치까지 되돌리려면 이력이 이 값을 봐야 한다. */
drainage: {
pipeChainages: () => number[];
setPipeChainages: (chainages: number[]) => void;
setPipeChainages: (chainages: number[] | null) => void;
};
/** 세션 복원 후 화면을 다시 세운다(편집 초안 재적재 포함). */
restore: () => void;
@@ -83,38 +83,26 @@ export interface PanelTools {
handleToolPick: (chainageM: number | null) => boolean;
/** 창 키 듣기를 잇고 뗀다 — 종단도가 숨거나 떠나면 뗀다(계획서 24-3a). */
setListening: (on: boolean) => void;
/** 사진첩 알림을 뗀다 — 종단도를 버릴 때. */
disposeTools: () => void;
}
export function createPanelTools(ctx: PanelToolsContext): PanelTools {
/** 이력이 처음 본 관 목록 — 최초 스냅샷에는 아직 키가 없어 여기로 되돌린다. */
let initialPipes: number[] | null = null;
/** 관 자리는 가게 관 초안(`pipes` · 사진에 듦)이 정본 — 찍기 직전엔 처음 본 목록만 잡아 둔다. */
function syncPipes(): void {
if (initialPipes === null) initialPipes = ctx.drainage.pipeChainages();
}
/** 스냅샷을 되돌린 뒤 관 위치도 그 시점 초안으로 맞춘다(초안 없는 걸음 = 처음 목록). 같은 목록이면 조용히 끝난다. */
/** 스냅샷을 되돌린 뒤 관 위치도 그 시점 초안으로 맞춘다 — 관 자리는 가게 관 초안(`pipes` · 사진에 듦)이
* 정본 · 초안 없는 걸음 = 저장분(null · 옛 「처음 본 목록」은 판이 읽기 전 빈 목록이라 관 하나 지움 ↶ 이
* 같은 목록으로 멈췄음 · 472 덧). 같은 목록이면 조용히 끝난다. */
function restorePipes(): void {
const draft = useDesignStore(draftProjectId(), null).get("pipes");
const chainages = draft
? draft.map((pipe) => Math.round(pipe.chainage_m * 100) / 100)
: initialPipes;
if (chainages) ctx.drainage.setPipeChainages(chainages);
ctx.drainage.setPipeChainages(
draft ? draft.map((pipe) => Math.round(pipe.chainage_m * 100) / 100) : null,
);
}
// 계획선 편집만 되돌리면 구조물 위치가 어긋난다(사용자 확정 ⑥ — 범위는 B05 조작 전부).
/** 찍기 직전(세울 때 · 쉬는 동안 온 값 덮을 때 · 연속 조작 멈출 때) 처음 본 관 목록을 잡는다 —
* 그래야 그 목록이 조작 **전** 값으로 잡혀 되돌리기가 시작 자리로 돌아온다(2026-09-04 실측). */
const inner = createProfileHistory(
() => {
ctx.restore();
restorePipes();
},
100,
syncPipes,
);
const history: ProfileHistory = { ...inner, pause: () => (syncPipes(), inner.pause()) };
const history = createProfileHistory(() => {
ctx.restore();
restorePipes();
}, 100);
setPageHistory(history); // 페이지 사진첩 하나 — 노선 편집기도 이것으로 쌓고 되돌림(346-4)
// [자유지정] — 적용은 직선화 결과와 같은 길(applyEdits · 이력 · 막기 규칙)(215-1).
@@ -173,10 +161,10 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
},
canResetToSaved: ctx.canRestoreSaved,
// 자유지정 동안 ↶ ↷ = 자유지정 안 한 걸음(367-3) — 큰 이력은 안 건드림.
onUndo: () => (tools.mode() === "free" ? (free.undo(), freeView.redraw()) : inner.undo()),
onRedo: () => (tools.mode() === "free" ? (free.redo(), freeView.redraw()) : inner.redo()),
canUndo: () => (tools.mode() === "free" ? free.canUndo() : inner.canUndo()),
canRedo: () => (tools.mode() === "free" ? free.canRedo() : inner.canRedo()),
onUndo: () => (tools.mode() === "free" ? (free.undo(), freeView.redraw()) : history.undo()),
onRedo: () => (tools.mode() === "free" ? (free.redo(), freeView.redraw()) : history.redo()),
canUndo: () => (tools.mode() === "free" ? free.canUndo() : history.canUndo()),
canRedo: () => (tools.mode() === "free" ? free.canRedo() : history.canRedo()),
hasStraightRuns: () => {
const alignment = ctx.alignment();
return alignment ? hasStraightRuns(alignment) : false;
@@ -400,5 +388,7 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
window.addEventListener("keydown", onEscape, true);
} else stopHold();
};
return { tools, history, handleToolPick, setListening };
// 삭제 직후 등 다시 그리기 전에도 ↶ ↷ 켜짐을 맞춤(472 덧 — 종단 ↶ 가 꺼진 채 남던 흠)
const disposeTools = onPageHistory({ change: () => tools.syncHistory() });
return { tools, history, handleToolPick, setListening, disposeTools };
}
@@ -50,6 +50,8 @@ export interface ProfileToolsCallbacks {
export interface ProfileTools {
/** 요약줄 맨 앞에 넣을 도구 묶음. 그릴 때마다 새로 만든다. */
render: () => HTMLElement;
/** 사진첩이 쌓이거나 되돌린 뒤 ↶ ↷ 켜짐만 맞춤 — 다시 그리기를 안 기다림(472 덧). */
syncHistory: () => void;
mode: () => ProfileToolMode;
/** 그래프에서 측점을 눌렀을 때 — 도구가 삼켰으면 true. */
handleStationPick: (chainageM: number) => boolean;
@@ -88,6 +90,8 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo
let mode: ProfileToolMode = "none";
let pending: number | null = null;
let runs: StraightRun[] = [];
/** 마지막에 그린 ↶ ↷ — 다시 그리기 사이에도 켜짐을 맞춤. */
let historyButtons: [HTMLButtonElement, HTMLButtonElement] | null = null;
function reset(): void {
mode = "none";
@@ -172,6 +176,11 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo
callbacks.onChanged();
return true;
},
syncHistory() {
if (!historyButtons) return;
historyButtons[0].disabled = !callbacks.canUndo();
historyButtons[1].disabled = !callbacks.canRedo();
},
render() {
const wrap = document.createElement("span");
wrap.className = "b05-route-profile__tools";
@@ -193,6 +202,7 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo
undo.setAttribute("aria-label", "되돌리기");
redo.innerHTML = HISTORY_ICON.redo;
redo.setAttribute("aria-label", "다시하기");
historyButtons = [undo, redo];
const straighten = toolButton(
"직선화",