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>
160 lines
6.7 KiB
TypeScript
160 lines
6.7 KiB
TypeScript
/* =============================================================================
|
|
* 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,
|
|
shiftStraightRuns,
|
|
straightenBetween,
|
|
tiltStraightRun,
|
|
} from "./B05_Profile_UI_Profile_Straighten";
|
|
import type { IrregularStation } from "./B05_Profile_UI_IrregularStations";
|
|
|
|
/** 방향키 한 번에 움직이는 양(m) — 계획고·누가거리 모두 같다(사용자 확정). */
|
|
const KEY_STEP_M = 0.1;
|
|
|
|
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;
|
|
/** 세션 복원 후 화면을 다시 세운다(편집 초안 재적재 포함). */
|
|
restore: () => void;
|
|
/** 도구 상태가 바뀌어 요약줄을 다시 그려야 할 때. */
|
|
refresh: () => void;
|
|
}
|
|
|
|
export interface PanelTools {
|
|
tools: ProfileTools;
|
|
history: ProfileHistory;
|
|
/** 그래프 클릭을 도구가 먹는지 — 먹었으면 기본 선택 동작을 건너뛴다. */
|
|
handleToolPick: (chainageM: number | null) => boolean;
|
|
}
|
|
|
|
export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
|
const history = createProfileHistory(ctx.restore);
|
|
|
|
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));
|
|
},
|
|
onUndo: () => history.undo(),
|
|
onRedo: () => history.redo(),
|
|
canUndo: () => history.canUndo(),
|
|
canRedo: () => history.canRedo(),
|
|
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 };
|
|
}
|