/* ============================================================================= * B05_Profile_UI_Profile_Panel_Tools.ts * 종단 패널의 [직선화]·[쉬프트]·되돌리기·방향키 배선 (2026-09-02 사용자 지시). * * 패널 본체(`B05_Profile_UI_Profile_Panel`)가 700줄 한계라 조작 배선만 떼어 놓는다. * 여기서는 상태를 갖지 않고 **본체가 넘긴 접근자**로만 현재 값을 읽는다 — 계획선·편집 * 델타의 주인은 그대로 본체다. * * 되돌리기 범위가 B05 조작 전부인 이유는 `B05_Profile_UI_Profile_History` 머리말 참조. * ========================================================================== */ import type { AlignmentBase, AlignmentEdits, ProfileAlignment, } from "./B05_Profile_UI_Profile_Alignment"; import { adjustStation } from "./B05_Profile_UI_Profile_Alignment"; import { createProfileHistory, type ProfileHistory } from "./B05_Profile_UI_Profile_History"; import { createProfileTools, type ProfileTools } from "./B05_Profile_UI_Profile_Tools"; import { detectStraightRun, hasStraightRuns, shiftStraightRuns, straightenBetween, tiltStraightRun, } from "./B05_Profile_UI_Profile_Straighten"; import type { IrregularStation } from "./B05_Profile_UI_IrregularStations"; /** 방향키 한 번에 움직이는 양(m) — 계획고·누가거리 모두 같다(사용자 확정). */ const KEY_STEP_M = 0.1; /** 구조물(관) 위치를 이력에 태우는 세션 키 — `b05:` 접두라 스냅샷 대상에 든다. */ const PIPE_KEY = "b05:pipes"; export interface PanelToolsContext { /** 패널 루트 — 키보드 조작이 이 안에서 일어났을 때만 반응한다. */ root: HTMLElement; base: () => AlignmentBase | null; alignment: () => ProfileAlignment | null; edits: () => AlignmentEdits; applyEdits: (next: AlignmentEdits) => void; /** 고른 측점 id(없으면 null). */ selectedStationId: () => string | null; /** 비정규(구조물) 측점 목록 — 좌우 이동 대상 판정에 쓴다. */ irregularStations: () => IrregularStation[]; /** 측점 id 만들기 — 목록 id와 그래프 id를 맞춘다. */ stationIdOf: (station: IrregularStation) => string; /** 구조물·비정규 측점을 다른 누가거리로 옮긴다(그래프 끌기와 같은 경로). */ moveStation: (station: IrregularStation, toChainageM: number) => void; /** 관 목록 정본 — 되돌리기가 구조물 위치까지 되돌리려면 이력이 이 값을 봐야 한다. */ drainage: { pipeChainages: () => number[]; setPipeChainages: (chainages: number[]) => void; }; /** 세션 복원 후 화면을 다시 세운다(편집 초안 재적재 포함). */ restore: () => void; /** 마지막 [저장] 시점으로 편집을 되돌린다(스토어 소관). */ restoreSaved: () => void; /** 되돌릴 것이 남아 있는가 — [초기화] 버튼 활성 조건. */ canRestoreSaved: () => boolean; /** 도구 상태가 바뀌어 요약줄을 다시 그려야 할 때. */ refresh: () => void; } export interface PanelTools { tools: ProfileTools; history: ProfileHistory; /** 그래프 클릭을 도구가 먹는지 — 먹었으면 기본 선택 동작을 건너뛴다. */ handleToolPick: (chainageM: number | null) => boolean; } export function createPanelTools(ctx: PanelToolsContext): PanelTools { /** 이력이 처음 본 관 목록 — 최초 스냅샷에는 아직 키가 없어 여기로 되돌린다. */ let initialPipes: number[] | null = null; /** 관 위치를 스냅샷이 볼 수 있는 세션 키로 옮겨 적는다(기록 직전에 부른다). */ function syncPipes(): void { const chainages = ctx.drainage.pipeChainages(); if (initialPipes === null) initialPipes = chainages; sessionStorage.setItem(PIPE_KEY, JSON.stringify(chainages)); } /** 스냅샷을 되돌린 뒤 관 위치도 그 시점 값으로 맞춘다. 같은 목록이면 조용히 끝난다. */ function restorePipes(): void { let chainages = initialPipes; const raw = sessionStorage.getItem(PIPE_KEY); if (raw) { try { chainages = JSON.parse(raw) as number[]; } catch { // 손상된 값은 최초 목록으로 되돌린다. } } if (chainages) ctx.drainage.setPipeChainages(chainages); } // 계획선 편집만 되돌리면 구조물 위치가 어긋난다(사용자 확정 ⑥ — 범위는 B05 조작 전부). const inner = createProfileHistory(() => { ctx.restore(); restorePipes(); }); /** 기록 직전에 관 목록을 세션으로 흘려 스냅샷에 같이 담기게 한다. */ const history: ProfileHistory = { ...inner, record: () => (syncPipes(), inner.record()) }; const tools = createProfileTools({ onStraighten: (fromM, toM) => { const base = ctx.base(); if (!base) return; ctx.applyEdits(straightenBetween(base, ctx.edits(), fromM, toM)); tools.clear(); }, onShift: (runs, delta) => { const base = ctx.base(); if (!base) return; ctx.applyEdits(shiftStraightRuns(base, ctx.edits(), runs, delta)); }, onTilt: (run, delta) => { const base = ctx.base(); if (!base) return; ctx.applyEdits(tiltStraightRun(base, ctx.edits(), run, delta)); }, onResetToSaved: () => { // 이력에 남겨 [↶]로 되살릴 수 있게 한다 — 잘못 눌러도 잃는 것이 없다. ctx.restoreSaved(); history.record(); }, canResetToSaved: ctx.canRestoreSaved, onUndo: () => inner.undo(), onRedo: () => inner.redo(), canUndo: () => inner.canUndo(), canRedo: () => inner.canRedo(), hasStraightRuns: () => { const alignment = ctx.alignment(); return alignment ? hasStraightRuns(alignment) : false; }, onChanged: ctx.refresh, }); /** 그래프에서 누른 자리를 도구가 먹는다. */ function handleToolPick(chainageM: number | null): boolean { const mode = tools.mode(); if (mode === "none") return false; if (chainageM === null) return tools.handleRunPick(null); const alignment = ctx.alignment(); if (mode === "shift") { return tools.handleRunPick(alignment ? detectStraightRun(alignment, chainageM) : null); } // 직선화 모드에서 첫 클릭이 **이미 직선화된 라인 안쪽**이면 그 라인을 골라 // 틸팅(가운데 라운드 + 양측 탄젠트) 대상으로 삼는다. if (tools.pendingStation() === null && alignment) { const run = detectStraightRun(alignment, chainageM); if (run && Math.abs(run.fromM - chainageM) > 1e-6 && Math.abs(run.toM - chainageM) > 1e-6) { return tools.handleRunPick(run); } } return tools.handleStationPick(chainageM); } function selectedIrregular(): IrregularStation | null { const stationId = ctx.selectedStationId(); if (stationId === null) return null; return ctx.irregularStations().find((entry) => ctx.stationIdOf(entry) === stationId) ?? null; } /** 고른 측점의 누가거리 — 비정규 측점이면 목록에서, 규칙 측점이면 선형에서 찾는다. */ function selectedChainage(): number | null { const irregular = selectedIrregular(); if (irregular) return irregular.chainage_m; const stationId = ctx.selectedStationId(); const row = ctx.alignment()?.stations.find((station) => station.station_id === stationId); return row ? row.chainage_m : null; } /* 방향키 — 상·하는 계획고(라운드 포함 기존 틸팅 경로), 좌·우는 누가거리. * 좌우는 구조물·비정규 측점만 움직인다. 20m 정규 측점은 격자라 옮기면 수량·도면 * 측점번호가 어긋난다(2026-09-02 사용자 확정). */ ctx.root.addEventListener("keydown", (event) => { const target = event.target as HTMLElement | null; // 입력칸 안에서는 방향키가 값 조작이므로 손대지 않는다. if (target && target.closest("input, select, textarea")) return; const key = event.key; if ((event.ctrlKey || event.metaKey) && (key === "z" || key === "Z")) { event.preventDefault(); if (event.shiftKey) history.redo(); else history.undo(); return; } if (key !== "ArrowUp" && key !== "ArrowDown" && key !== "ArrowLeft" && key !== "ArrowRight") { return; } const chainage = selectedChainage(); const base = ctx.base(); if (chainage === null || !base) return; if (key === "ArrowUp" || key === "ArrowDown") { event.preventDefault(); const delta = key === "ArrowUp" ? KEY_STEP_M : -KEY_STEP_M; ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta)); return; } const station = selectedIrregular(); if (!station) return; // 규칙 측점은 좌우 이동 대상이 아니다 — 조용히 무시한다. event.preventDefault(); const next = Number( (station.chainage_m + (key === "ArrowRight" ? KEY_STEP_M : -KEY_STEP_M)).toFixed(3), ); if (next < 0) return; ctx.moveStation(station, next); history.record(); }); return { tools, history, handleToolPick }; }