Files
Aislo/B05_Profile/B05_Profile_UI_Profile_Tools.ts
T
eomsangdonandClaude Opus 5 3651ad9875 feat(B05): 종단 계획선 전체 측점 폴리라인 전환 + 직선화·쉬프트·undo/redo 신설
2026-09-02 사용자 지시 9건 반영. 화면 실조작 검증은 다음 세션 몫 (사용자 지시로
코딩까지만 진행).

1. 초기 계획선 = 전체 측점 폴리라인 — `design_ground_following_profile()` 신설.
   모든 측점을 변화점으로 잡고 계획고 = 원지반고, 라운드는 R을 지정한 자리에만
   (`build_curves(only_explicit=)`·`build_alignment(only_explicit_curves=)`).
   basis `ground_polyline`. 관 정착 선형은 폴백으로 내림.
2. 구간 쉬프트(⬆⬇) 삭제.
3. [직선화] 신설 — `B05_Profile_UI_Profile_Straighten.ts`. 두 측점의 라운드에 탄젠트한
   직선으로 대체하고 사이 라운드 삭제. 직선 틸팅 시 가운데 라운드 + 양측 탄젠트 재구성.
4. [쉬프트] 신설 — 직선 구간을 기하에서 되읽어(`detectStraightRun`) 복수 선택,
   최외곽 라운드 중심 기준 상·하 평행이동.
5. 방향키 조작 — 상하 0.1m 계획고, 좌우 0.1m 누가거리(구조물·비정규 측점 한정).
6. undo/redo 신설 — B05·B06 조작 세션 키 묶음 스냅샷(`_Profile_History.ts`).
   버튼은 요약줄 맨 앞(최대 기울기 좌측), 21x17px. Ctrl+Z / Ctrl+Shift+Z.
7. 종단 요약줄의 횡단배수 최소고 표시 삭제(산식·편집 차단은 유지).
8. [편집 되돌리기] 버튼 삭제 — undo/redo로 대체.
9. 지형 구분 기본값 특수지형 — 패널 셀렉트와 백엔드 기본값(스키마·체인 폴백) 일치.

700줄 제한 — 패널을 `_Profile_Panel_Tools` · `_Profile_Preview` 로 분리하고 측점↔구조물
짝짓기를 `_Profile_Structures` 로 이관(842줄 → 696줄).

검증: tsc --noEmit 오류 0, ruff format/check 통과,
pytest tmp/tests/ -q → 366 passed / 14 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 19:30:16 +09:00

181 lines
6.7 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_Profile_Tools.ts
* 종단 계획선 도구줄 — [↶][↷] · [직선화] · [쉬프트] · [▲][▼] (2026-09-02 사용자 지시).
*
* 자리는 절·성토 요약줄의 **맨 앞**(최대 기울기 칩 왼쪽)이고, 버튼 크기는 그래프 위
* 틸팅 버튼과 같다(`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;
onUndo: () => void;
onRedo: () => void;
canUndo: () => boolean;
canRedo: () => 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 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(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 = "직선화된 라인을 고르세요";
wrap.append(hint);
}
return wrap;
},
};
}