Files
Aislo/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts
T
eomsangdonandClaude Opus 5 66717162c5 feat(B05): 도구줄 [초기화] 추가 + 쉬프트 선택 구간 빨간 강조
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>
2026-09-03 20:42:23 +09:00

213 lines
9.0 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,
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 };
}