main_laptop_1 -> main byeonghap (4 hwangyeong 585 commits) #12
@@ -23,8 +23,8 @@ const DRAFT_KEY_PREFIX = "b05-profile-alignment-draft";
|
||||
const BUTTON_CLEARANCE_PX = 23;
|
||||
const BUTTON_HALF_PX = 10;
|
||||
/** 길게 누르기: 이만큼 유지하면 반복이 시작되고, 그 뒤 초당 10회(0.1m씩)로 이어진다. */
|
||||
const HOLD_DELAY_MS = 500;
|
||||
const HOLD_INTERVAL_MS = 100;
|
||||
export const HOLD_DELAY_MS = 500;
|
||||
export const HOLD_INTERVAL_MS = 100;
|
||||
|
||||
/**
|
||||
* 길게 누르는 동안 같은 동작을 반복한다.
|
||||
|
||||
@@ -75,6 +75,10 @@ function restoreSnapshot(snapshot: HistorySnapshot): void {
|
||||
export interface ProfileHistory {
|
||||
/** 조작이 끝난 뒤 현재 상태를 이력에 쌓는다(직전과 같으면 무시). */
|
||||
record(): void;
|
||||
/** 연속 조작(길게 누르기) 동안 기록을 미룬다 — 20칸 이동이 되돌리기 20번이 되는 것을 막는다. */
|
||||
pause(): void;
|
||||
/** 연속 조작이 끝났음을 알린다 — 그 동안의 변화를 **한 덩어리로** 한 번만 쌓는다. */
|
||||
resume(): void;
|
||||
undo(): boolean;
|
||||
redo(): boolean;
|
||||
canUndo(): boolean;
|
||||
@@ -92,6 +96,8 @@ export function createProfileHistory(onRestore: () => void, limit = 50): Profile
|
||||
let cursor = 0;
|
||||
/** 복원 중에 들어오는 record()를 무시한다 — 복원이 새 이력을 만들면 redo가 사라진다. */
|
||||
let restoring = false;
|
||||
/** 연속 조작 중에는 record()를 흘려보내고 resume()에서 한 번만 쌓는다. */
|
||||
let paused = false;
|
||||
|
||||
function apply(index: number): boolean {
|
||||
if (index < 0 || index >= stack.length) return false;
|
||||
@@ -108,7 +114,7 @@ export function createProfileHistory(onRestore: () => void, limit = 50): Profile
|
||||
|
||||
return {
|
||||
record() {
|
||||
if (restoring) return;
|
||||
if (restoring || paused) return;
|
||||
const snapshot = takeSnapshot();
|
||||
if (sameSnapshot(snapshot, stack[cursor])) return;
|
||||
stack.splice(cursor + 1);
|
||||
@@ -116,6 +122,14 @@ export function createProfileHistory(onRestore: () => void, limit = 50): Profile
|
||||
if (stack.length > limit) stack.shift();
|
||||
cursor = stack.length - 1;
|
||||
},
|
||||
pause() {
|
||||
paused = true;
|
||||
},
|
||||
resume() {
|
||||
if (!paused) return;
|
||||
paused = false;
|
||||
this.record();
|
||||
},
|
||||
undo: () => apply(cursor - 1),
|
||||
redo: () => apply(cursor + 1),
|
||||
canUndo: () => cursor > 0,
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
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 {
|
||||
@@ -99,8 +100,14 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
||||
ctx.restore();
|
||||
restorePipes();
|
||||
});
|
||||
/** 기록 직전에 관 목록을 세션으로 흘려 스냅샷에 같이 담기게 한다. */
|
||||
const history: ProfileHistory = { ...inner, record: () => (syncPipes(), inner.record()) };
|
||||
/** 기록 직전에 관 목록을 세션으로 흘려 스냅샷에 같이 담기게 한다.
|
||||
* 연속 조작을 멈출 때(pause)도 같이 흘린다 — 그래야 "처음 본 관 목록"이 조작 **전**
|
||||
* 값으로 잡혀 되돌리기가 시작 자리로 돌아온다(2026-09-04 실측: 한 칸 덜 돌아왔음). */
|
||||
const history: ProfileHistory = {
|
||||
...inner,
|
||||
record: () => (syncPipes(), inner.record()),
|
||||
pause: () => (syncPipes(), inner.pause()),
|
||||
};
|
||||
|
||||
const tools = createProfileTools({
|
||||
onStraighten: (fromM, toM) => {
|
||||
@@ -173,14 +180,110 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
||||
|
||||
/* 방향키 — 상·하는 계획고(라운드 포함 기존 틸팅 경로), 좌·우는 누가거리.
|
||||
* 좌우는 구조물·비정규 측점만 움직인다. 20m 정규 측점은 격자라 옮기면 수량·도면
|
||||
* 측점번호가 어긋난다(2026-09-02 사용자 확정). */
|
||||
ctx.root.addEventListener("keydown", (event) => {
|
||||
* 측점번호가 어긋난다(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;
|
||||
@@ -188,24 +291,20 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
||||
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") {
|
||||
// OS 키 반복은 무시한다 — 속도는 위 타이머가 정한다.
|
||||
if (event.repeat) {
|
||||
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; // 규칙 측점은 좌우 이동 대상이 아니다 — 조용히 무시한다.
|
||||
// 기록을 먼저 멈춘다 — 첫 한 번까지 같은 덩어리에 들어가야 되돌리기 1회로 원위치한다.
|
||||
flushHold();
|
||||
history.pause();
|
||||
if (!nudge(key)) {
|
||||
history.resume(); // 움직인 것이 없으므로 이력에 남지 않는다.
|
||||
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();
|
||||
startHold(key);
|
||||
});
|
||||
|
||||
return { tools, history, handleToolPick };
|
||||
|
||||
@@ -50,6 +50,8 @@ export interface ProfileTools {
|
||||
selectedRuns: () => StraightRun[];
|
||||
/** 직선화 대기 중 첫 측점(강조 표시용). */
|
||||
pendingStation: () => number | null;
|
||||
/** 고른 직선을 ▲▼ 버튼과 같은 경로로 옮긴다(방향키가 같이 쓴다) — 대상이 없으면 false. */
|
||||
nudge: (delta: number) => boolean;
|
||||
/** 모드·선택을 모두 끈다. */
|
||||
clear: () => void;
|
||||
}
|
||||
@@ -89,16 +91,19 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo
|
||||
callbacks.onChanged();
|
||||
}
|
||||
|
||||
function step(delta: number): void {
|
||||
function step(delta: number): boolean {
|
||||
if (mode === "shift" && runs.length) {
|
||||
callbacks.onShift(runs, delta);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (runs.length === 1) callbacks.onTilt(runs[0], delta);
|
||||
if (runs.length !== 1) return false;
|
||||
callbacks.onTilt(runs[0], delta);
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
mode: () => mode,
|
||||
nudge: step,
|
||||
selectedRuns: () => runs,
|
||||
pendingStation: () => pending,
|
||||
clear() {
|
||||
@@ -170,8 +175,8 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo
|
||||
);
|
||||
shift.classList.toggle("is-active", mode === "shift");
|
||||
|
||||
const up = toolButton("▲", "고른 직선을 0.1m 올림", () => step(0.1));
|
||||
const down = toolButton("▼", "고른 직선을 0.1m 내림", () => step(-0.1));
|
||||
const up = toolButton("▲", "고른 직선을 0.1m 올림", () => void step(0.1));
|
||||
const down = toolButton("▼", "고른 직선을 0.1m 내림", () => void step(-0.1));
|
||||
const idle = mode === "none" || (!runs.length && mode === "shift");
|
||||
up.disabled = idle || (mode === "straighten" && runs.length !== 1);
|
||||
down.disabled = up.disabled;
|
||||
|
||||
Reference in New Issue
Block a user