- 방향키 청취를 패널 루트에서 window 로 옮김 — 그래프 클릭 뒤 포커스가 body 에 남아 방향키가 아무 반응이 없던 결함 수정 (패널이 화면에 없으면 무시) - 길게 누르면 편집 버튼과 같은 속도로 연속 이동 (0.5초 뒤 0.1초 간격, OS 키 반복 무시) - 이력에 pause()/resume() 추가 — 연속 이동 전체가 되돌리기 1회로 원위치. 구조물 이동은 서버 재계산이 뒤따르므로 키를 뗀 뒤 700ms 뒤 기록 - pause() 에서도 관 목록을 세션에 흘려 되돌리기 기준을 조작 전 값으로 고정 - 좌우 연속 이동 중에는 처음 잡은 구조물 측점·자체 누가거리 카운터 사용 — 관 측점 id 가 누가거리 기반이라 한 번 옮기면 선택이 풀려 한 칸만 움직이던 결함 수정 - ProfileTools.nudge(delta) 노출 — [쉬프트] 모드의 상·하 방향키가 도구 ▲▼ 와 같은 경로(onShift)로 구간 평행이동 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
312 lines
14 KiB
TypeScript
312 lines
14 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 { HOLD_DELAY_MS, HOLD_INTERVAL_MS } from "./B05_Profile_UI_Profile_Edit";
|
|
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();
|
|
});
|
|
/** 기록 직전에 관 목록을 세션으로 흘려 스냅샷에 같이 담기게 한다.
|
|
* 연속 조작을 멈출 때(pause)도 같이 흘린다 — 그래야 "처음 본 관 목록"이 조작 **전**
|
|
* 값으로 잡혀 되돌리기가 시작 자리로 돌아온다(2026-09-04 실측: 한 칸 덜 돌아왔음). */
|
|
const history: ProfileHistory = {
|
|
...inner,
|
|
record: () => (syncPipes(), inner.record()),
|
|
pause: () => (syncPipes(), inner.pause()),
|
|
};
|
|
|
|
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 사용자 확정).
|
|
* 누르고 있으면 편집 버튼과 **같은 속도**로 이어진다(0.5초 뒤 초당 10회) — OS 키 반복에
|
|
* 맡기면 기기마다 속도가 갈린다. 그 동안의 변화는 이력 한 덩어리다(2026-09-04 사용자 지시). */
|
|
type ArrowKey = "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight";
|
|
|
|
/** 방향키 한 번의 조작 — 반복 타이머가 같은 함수를 다시 부른다. 대상이 없으면 false. */
|
|
function nudge(key: ArrowKey): boolean {
|
|
const base = ctx.base();
|
|
if (!base) return false;
|
|
if (key === "ArrowUp" || key === "ArrowDown") {
|
|
const delta = key === "ArrowUp" ? KEY_STEP_M : -KEY_STEP_M;
|
|
// [쉬프트]가 켜져 있으면 고른 직선 구간의 평행이동 — 도구 ▲▼와 같은 경로다.
|
|
if (tools.mode() === "shift") return tools.nudge(delta);
|
|
const chainage = selectedChainage();
|
|
if (chainage === null) return false;
|
|
ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta));
|
|
return true;
|
|
}
|
|
// 옮기는 동안에는 처음 잡은 측점을 계속 쓴다 — 관 측점 id 가 누가거리로 만들어져
|
|
// (`pipe-85.59`) 한 번 옮기면 선택이 풀리고 두 번째 키부터 먹지 않았다(2026-09-04 실측).
|
|
const station = holdStation ?? selectedIrregular();
|
|
if (!station) return false; // 규칙 측점은 좌우 이동 대상이 아니다 — 조용히 무시한다.
|
|
// 목록(정본)은 서버 재계산 뒤에야 새 누가거리를 들고 온다 — 연속 이동 중에는 그것을
|
|
// 기다리지 못하므로 지금 자리를 여기서 센다. 안 그러면 매 반복이 같은 자리를 다시 지시해
|
|
// 0.1m 만 움직이고 멈춤(2026-09-04 실측).
|
|
const fromM = holdChainageM ?? station.chainage_m;
|
|
const next = Number((fromM + (key === "ArrowRight" ? KEY_STEP_M : -KEY_STEP_M)).toFixed(3));
|
|
if (next < 0) return false;
|
|
ctx.moveStation({ ...station, chainage_m: fromM }, next);
|
|
holdStation = station;
|
|
holdChainageM = next;
|
|
history.record();
|
|
return true;
|
|
}
|
|
|
|
let heldKey: ArrowKey | null = null;
|
|
/** 좌우 연속 이동 중의 현재 누가거리 — 정본 목록이 따라오기 전까지 여기서 센다. */
|
|
let holdChainageM: number | null = null;
|
|
/** 옮기는 중인 구조물 측점 — 선택이 풀려도 키를 뗄 때까지 이 측점을 움직인다. */
|
|
let holdStation: IrregularStation | null = null;
|
|
let holdDelayTimer = 0;
|
|
let holdRepeatTimer = 0;
|
|
|
|
/** 구조물 이동은 서버 재계산을 거쳐 돌아오고 그 뒷정리(유령 변화점 삭제)도 이력을
|
|
* 건드린다 — 키를 뗀 뒤 이만큼 기다렸다 한 덩어리로 기록한다. */
|
|
const HOLD_SETTLE_MS = 700;
|
|
let settleTimer = 0;
|
|
|
|
/** 반복을 끊고, 잠시 뒤 그 동안의 변화를 이력 한 덩어리로 남긴다. */
|
|
function stopHold(): void {
|
|
window.clearTimeout(holdDelayTimer);
|
|
window.clearInterval(holdRepeatTimer);
|
|
holdDelayTimer = 0;
|
|
holdRepeatTimer = 0;
|
|
if (heldKey === null) return;
|
|
heldKey = null;
|
|
holdChainageM = null;
|
|
holdStation = null;
|
|
window.removeEventListener("keyup", onKeyUp);
|
|
window.removeEventListener("blur", stopHold);
|
|
window.clearTimeout(settleTimer);
|
|
settleTimer = window.setTimeout(() => {
|
|
settleTimer = 0;
|
|
history.resume();
|
|
}, HOLD_SETTLE_MS);
|
|
}
|
|
|
|
/** 기다리지 않고 지금 바로 한 덩어리를 닫는다(다음 조작·되돌리기 직전). */
|
|
function flushHold(): void {
|
|
stopHold();
|
|
if (!settleTimer) return;
|
|
window.clearTimeout(settleTimer);
|
|
settleTimer = 0;
|
|
history.resume();
|
|
}
|
|
|
|
function onKeyUp(event: KeyboardEvent): void {
|
|
if (event.key === heldKey) stopHold();
|
|
}
|
|
|
|
/** 첫 한 번은 부른 쪽에서 이미 움직였다 — 여기서는 이어지는 반복만 건다. */
|
|
function startHold(key: ArrowKey): void {
|
|
heldKey = key;
|
|
window.addEventListener("keyup", onKeyUp);
|
|
window.addEventListener("blur", stopHold);
|
|
holdDelayTimer = window.setTimeout(() => {
|
|
holdRepeatTimer = window.setInterval(() => {
|
|
if (!nudge(key)) stopHold(); // 더 움직일 곳이 없으면 스스로 멈춘다.
|
|
}, HOLD_INTERVAL_MS);
|
|
}, HOLD_DELAY_MS);
|
|
}
|
|
|
|
/* 듣는 자리는 **창(window)** 이다 — 패널에 걸어 두면 그래프를 눌러도 포커스가 body에
|
|
* 남아(측점을 고르면 그래프가 다시 그려져 포커스가 풀림) 방향키가 아무 반응이 없었다
|
|
* (2026-09-04 실측). 패널이 화면에서 빠지면(다른 페이지) 조용히 무시한다. */
|
|
window.addEventListener("keydown", (event) => {
|
|
if (!ctx.root.isConnected) return;
|
|
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();
|
|
flushHold();
|
|
if (event.shiftKey) history.redo();
|
|
else history.undo();
|
|
return;
|
|
}
|
|
if (key !== "ArrowUp" && key !== "ArrowDown" && key !== "ArrowLeft" && key !== "ArrowRight") {
|
|
return;
|
|
}
|
|
// OS 키 반복은 무시한다 — 속도는 위 타이머가 정한다.
|
|
if (event.repeat) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
// 기록을 먼저 멈춘다 — 첫 한 번까지 같은 덩어리에 들어가야 되돌리기 1회로 원위치한다.
|
|
flushHold();
|
|
history.pause();
|
|
if (!nudge(key)) {
|
|
history.resume(); // 움직인 것이 없으므로 이력에 남지 않는다.
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
startHold(key);
|
|
});
|
|
|
|
return { tools, history, handleToolPick };
|
|
}
|