/* ============================================================================= * B05_Profile_UI_Profile_Tools.ts * 종단 계획선 도구줄 — [초기화] · [↶][↷] · [직선화] · [쉬프트] · [▲][▼]. * ([초기화]는 2026-09-03 추가 — 마지막 [저장] 시점 기준이며 전체 초기화가 아니다.) * * 자리는 절·성토 요약줄의 **맨 앞**(최대 기울기 칩 왼쪽)이고, 버튼 크기는 그래프 위 * 틸팅 버튼과 같다(`b05-profile-edit__btn` 계열 크기를 CSS에서 공유). * * 선택 흐름: * [직선화] → 측점 2개를 그래프에서 고름 → 두 측점의 라운드에 탄젠트한 직선으로 대체. * [쉬프트] → 직선화된 라인을 고름(여러 개 가능) → [▲][▼]로 통째로 올리고 내림. * 직선을 고른 채 [직선화] 모드에서 [▲][▼]를 누르면 그 직선이 꺾이며 가운데에 * 라운드가 생기고 양쪽이 탄젠트 직선이 된다. * ========================================================================== */ import type { StraightRun } from "./B05_Profile_UI_Profile_Straighten"; import { sameRun } from "./B05_Profile_UI_Profile_Straighten"; export type ProfileToolMode = "none" | "straighten" | "shift"; export interface ProfileToolsCallbacks { /** 두 측점을 직선으로 잇는다. */ onStraighten: (fromChainageM: number, toChainageM: number) => void; /** 고른 직선 구간(들)을 위·아래로 옮긴다. */ onShift: (runs: StraightRun[], delta: number) => void; /** 고른 직선 구간을 꺾는다 — 가운데 라운드 + 양측 탄젠트. */ onTilt: (run: StraightRun, delta: number) => void; /** 마지막 [저장] 시점으로 되돌린다 — 전체 초기화가 아니다. */ onResetToSaved: () => void; canResetToSaved: () => boolean; onUndo: () => void; onRedo: () => void; canUndo: () => boolean; canRedo: () => boolean; /** 지금 계획선에 직선화된 구간이 있는가 — [쉬프트] 안내 문구를 가른다. */ hasStraightRuns: () => boolean; /** 선택 표시를 갱신해야 할 때(모드·선택 변화) 호출된다. */ onChanged: () => void; } export interface ProfileTools { /** 요약줄 맨 앞에 넣을 도구 묶음. 그릴 때마다 새로 만든다. */ render: () => HTMLElement; mode: () => ProfileToolMode; /** 그래프에서 측점을 눌렀을 때 — 도구가 삼켰으면 true. */ handleStationPick: (chainageM: number) => boolean; /** 그래프에서 직선을 눌렀을 때(구간 판정 결과) — 도구가 삼켰으면 true. */ handleRunPick: (run: StraightRun | null) => boolean; /** 선택 중인 직선 구간들(강조 표시용). */ selectedRuns: () => StraightRun[]; /** 직선화 대기 중 첫 측점(강조 표시용). */ pendingStation: () => number | null; /** 고른 직선을 ▲▼ 버튼과 같은 경로로 옮긴다(방향키가 같이 쓴다) — 대상이 없으면 false. */ nudge: (delta: number) => boolean; /** 모드·선택을 모두 끈다. */ clear: () => void; } function toolButton(label: string, title: string, onClick: () => void): HTMLButtonElement { const button = document.createElement("button"); button.type = "button"; button.className = "b05-route-profile__tool"; button.textContent = label; button.title = title; button.addEventListener("click", (event) => { event.stopPropagation(); onClick(); }); return button; } export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileTools { let mode: ProfileToolMode = "none"; let pending: number | null = null; let runs: StraightRun[] = []; function reset(): void { mode = "none"; pending = null; runs = []; } function setMode(next: ProfileToolMode): void { // 같은 버튼을 다시 누르면 모드를 끈다 — 선택도 함께 비운다. if (mode === next) reset(); else { mode = next; pending = null; runs = []; } callbacks.onChanged(); } function step(delta: number): boolean { if (mode === "shift" && runs.length) { callbacks.onShift(runs, delta); return true; } if (runs.length !== 1) return false; callbacks.onTilt(runs[0], delta); return true; } return { mode: () => mode, nudge: step, selectedRuns: () => runs, pendingStation: () => pending, clear() { if (mode === "none" && pending === null && !runs.length) return; reset(); callbacks.onChanged(); }, handleStationPick(chainageM) { if (mode !== "straighten") return false; if (pending === null) { pending = chainageM; callbacks.onChanged(); return true; } const from = pending; pending = null; if (Math.abs(from - chainageM) < 1e-6) { callbacks.onChanged(); return true; } callbacks.onStraighten(from, chainageM); return true; }, handleRunPick(run) { if (mode === "none") return false; if (!run) { // 빈 곳을 누르면 선택만 비우고 모드는 유지한다 — 연속 조작을 끊지 않는다. if (runs.length || pending !== null) { runs = []; pending = null; callbacks.onChanged(); } return true; } const already = runs.findIndex((entry) => sameRun(entry, run)); if (already >= 0) runs.splice(already, 1); else if (mode === "shift") runs.push(run); else runs = [run]; callbacks.onChanged(); return true; }, render() { const wrap = document.createElement("span"); wrap.className = "b05-route-profile__tools"; // [초기화] — 되돌리기 왼쪽. 기준은 **마지막 [저장] 시점**이고 저장한 작업은 남는다. const reset = toolButton( "초기화", "마지막 [저장] 시점으로 되돌립니다 (저장한 작업은 그대로 남습니다)", callbacks.onResetToSaved, ); reset.disabled = !callbacks.canResetToSaved(); const undo = toolButton("↶", "되돌리기 (Ctrl+Z)", callbacks.onUndo); undo.disabled = !callbacks.canUndo(); const redo = toolButton("↷", "다시하기 (Ctrl+Shift+Z)", callbacks.onRedo); redo.disabled = !callbacks.canRedo(); const straighten = toolButton( "직선화", "측점 2개를 골라 그 사이를 직선으로 만듭니다 (사이 라운드는 지워집니다)", () => setMode("straighten"), ); straighten.classList.toggle("is-active", mode === "straighten"); const shift = toolButton( "쉬프트", "직선화된 라인을 골라(여러 개 가능) 위·아래로 옮깁니다", () => setMode("shift"), ); shift.classList.toggle("is-active", mode === "shift"); const up = toolButton("▲", "고른 직선을 0.1m 올림", () => void step(0.1)); const down = toolButton("▼", "고른 직선을 0.1m 내림", () => void step(-0.1)); const idle = mode === "none" || (!runs.length && mode === "shift"); up.disabled = idle || (mode === "straighten" && runs.length !== 1); down.disabled = up.disabled; wrap.append(reset, undo, redo, straighten, shift, up, down); if (mode === "straighten" && pending !== null) { const hint = document.createElement("em"); hint.className = "b05-route-profile__tool-hint"; hint.textContent = `${pending.toFixed(1)}m 선택 — 두 번째 측점을 고르세요 (취소는 [직선화] 다시 누르기)`; wrap.append(hint); } else if (mode === "shift" && !runs.length) { const hint = document.createElement("em"); hint.className = "b05-route-profile__tool-hint"; // 고를 것이 아예 없는 경우와 있는데 안 고른 경우를 갈라 적는다 — 초기 계획선은 // 마디마다 기울기가 달라 직선 구간이 없어, 눌러도 안 잡히는 것이 정상이다. hint.textContent = callbacks.hasStraightRuns() ? "직선화된 라인을 고르세요 (여러 개 가능) — 끝내려면 [쉬프트]를 다시 누르세요" : "직선화된 구간이 없습니다 — [직선화]로 먼저 만드세요"; wrap.append(hint); } return wrap; }, }; }