1. [초기화] — [↶] 왼쪽에 둠. 전체 초기화가 아니라 **마지막 [저장] 시점**이 기준점임 (사용자 지시). 편집 스토어가 저장 시점 편집분(`savedBaseline`)을 들고 있다가 그리로 되돌림 — 저장한 작업은 남고 그 뒤 편집만 버림. 이력에 기록해 [↶]로 되살릴 수 있음. 되돌릴 것이 없으면 버튼은 비활성. 자동 선형까지 지우는 `resetAll`(좌측 [초기화])과는 다른 조작임. 2. [쉬프트]로 고른 직선이 화면에 표시되지 않아 무엇이 잡혔는지 알 수 없었음. 고른 구간을 빨갛게 덧그림 — 범위는 **직선 + 양 끝 라운드(R)** 임. 쉬프트는 직선을 통째로 올리고 내리므로 양 끝 호의 모양까지 함께 바뀜. 구간 시작 라운드의 BVC부터 끝 라운드의 EVC 까지 계획선 샘플을 그대로 덧그려 곡률이 따라옴. 경계에는 점선 세로 표식. 공용 종단 렌더러(`B06_Section_UI_Longitudinal`)는 건드리지 않고 별도 오버레이로 얹음 (`B05_Profile_UI_Profile_RunHighlight.ts`). Y 매핑은 그 렌더러가 넘겨 주는 축 눈금에서 되짚어 같은 스케일을 씀. 편집 버튼층(z-index 4) 아래(3)라 버튼을 가리지 않음. `tsc --noEmit` 통과, 전체 테스트 183건 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
199 lines
7.9 KiB
TypeScript
199 lines
7.9 KiB
TypeScript
/* =============================================================================
|
|
* 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;
|
|
/** 모드·선택을 모두 끈다. */
|
|
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): void {
|
|
if (mode === "shift" && runs.length) {
|
|
callbacks.onShift(runs, delta);
|
|
return;
|
|
}
|
|
if (runs.length === 1) callbacks.onTilt(runs[0], delta);
|
|
}
|
|
|
|
return {
|
|
mode: () => mode,
|
|
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 올림", () => step(0.1));
|
|
const down = toolButton("▼", "고른 직선을 0.1m 내림", () => 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;
|
|
},
|
|
};
|
|
}
|