Files
Aislo/B05_Profile/B05_Profile_UI_RouteEdit_Keys.ts
T
eomsangdonandClaude Opus 5.5 97b5a78695 fix(441 B05 스냅 끌기): 맞춤 교점 이동 한도 = 간격 · 미리보기 측점 밖 늘면 그 걸음 되돌림 · 초안 없는 측점 단계 = 저장 스냅
- StationFit reach = intervalM(반 간격이면 MC 18+9 ~ 19+1 못 맞춤)
- 끌기 · 방향키 — 미리보기 측점 밖 곡선 수가 누를 때보다 늘면 그 걸음 되돌림 · dragMoved 는 막힘 검사 뒤(433-14)
- 계산 줄 stations 단계 · follow 새 측점 — 초안 없으면 저장 노선 station_snap · station_snap_step(snapOptions)
- 시험 — 390_5 끌기 훑기(놓은 뒤 측점 밖 0 · 자동 · 고정 R) · 344_3 저장 스냅 · 266 사면 이동 한 걸음 · 다시 그림 한 번

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-18 18:42:06 +09:00

161 lines
6.9 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_RouteEdit_Keys.ts
* 계획노선 편집 평면의 **교각점 방향키**(383-2 · 요구 241-16) — 고른 IP 를 화면 축으로 0.1 m
* (Shift 1 m)씩 · 측점 스냅 켬이면 스냅 간격씩(390-5 · 걸음마다 MC 를 간격 배수에 맞춘 미리보기).
* 뗄 때 되돌리기 한 걸음(측점 스냅 켬이면 그때 정본 맞춤).
*
* 받는 때 = 마지막 누름이 평면 편집 창(`.b05-routeedit`) 안 · 입력칸 아님. 그때 방향키는 평면이
* 삼킴(IP 를 안 골랐어도) — 종단 계획고 ▲▼ · 횡단 조정창으로 새지 않음. 하한 검사 · 놓을 때 할
* 일은 끌기(`_Pointer`)와 같음. 걸음 · 결과 한 줄 셈은 `logic/B05_Profile_RouteEdit_KeyNudge`.
* ========================================================================== */
import { lastPointerWithin } from "../B06_Section/B06_Section_UI_Cross_Panel_Base";
import { HOLD_DELAY_MS, HOLD_INTERVAL_MS } from "./B05_Profile_UI_Profile_Edit";
import type { RouteEditPointerParams } from "./B05_Profile_UI_RouteEdit_Pointer";
import {
arrowStep,
isArrowKey,
nudgeMessage,
nudgeReport,
type ArrowKey,
} from "./logic/B05_Profile_RouteEdit_KeyNudge";
import type { Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve";
import { curveShortfalls, shortfallCrossed } from "./logic/B05_Profile_UI_RouteEdit_Edits";
/** 누르는 한 번(누름 ~ 뗌) — 뗄 때 결과 한 줄을 셈. */
interface Hold {
key: ArrowKey;
shift: boolean;
index: number;
before: Vertex[];
start: Vertex;
/** 스냅 미리보기 전 노선(맞추기 전) — 다음 걸음 · 하한 검사는 여기서(맞춤이 쌓이지 않음). */
raw: Vertex[] | null;
/** 누를 때 측점 밖 곡선 수(441) — 미리보기가 이보다 늘면 그 걸음 되돌림. */
off: number;
moved: boolean;
}
export function bindRouteEditKeys(params: RouteEditPointerParams, dragging: () => boolean): void {
const { canvas, status } = params;
let hold: Hold | null = null;
let timer = 0;
const metricAt = (x: number, y: number): Vertex => params.toMetric(...params.unrotate(x, y));
/** 스냅 미리보기 — 맞추기 전 노선을 쥔 뒤 맞춤(끔이면 쥐지 않음). */
function fitPreview(now: Hold, planned: Vertex[]): void {
const raw = planned.map((vertex): Vertex => [vertex[0], vertex[1]]);
now.raw = params.fitDrag(now.index) ? raw : null;
}
/** 한 걸음 — 하한을 새로 깨면 그 걸음만 되돌림(끌기와 같은 검사). */
function step(now: Hold): void {
const state = params.state();
if (now.raw) {
now.raw.forEach((vertex, index) => (state.planned[index] = vertex));
params.markEdited();
}
const node = state.planned[now.index];
if (!node) return;
const snapStepM = params.stations() !== null ? params.snapStepM() : undefined;
const [dx, dy] = arrowStep(now.key, now.shift, metricAt, snapStepM);
const before = curveShortfalls(state.nodeInfo, state.limitRadiusM, state.limitArcM);
state.planned[now.index] = [node[0] + dx, node[1] + dy];
params.markEdited();
const after = params.state();
if (
shortfallCrossed(before, curveShortfalls(after.nodeInfo, after.limitRadiusM, after.limitArcM))
) {
state.planned[now.index] = node;
params.markEdited();
fitPreview(now, state.planned);
status.textContent =
`${params.routeHead()} — 하한에 걸려 더 못 옮깁니다` +
`(곡선반지름 ${state.limitRadiusM}m${state.limitArcM > 0 ? ` · 곡선 길이 ${state.limitArcM}m` : ""}).`;
params.draw();
return;
}
fitPreview(now, state.planned);
if (now.raw && params.offStation() > now.off) {
// 맞춰도 MC 가 측점 밖에 남는 자리 — 끌기와 같게 그 걸음만 되돌림(441)
const planned = params.state().planned;
now.raw.forEach((vertex, index) => (planned[index] = vertex));
planned[now.index] = node;
fitPreview(now, planned);
status.textContent = `${params.routeHead()} — 곡선 가운데를 측점에 못 맞춰 더 못 옮깁니다.`;
params.draw();
return;
}
now.moved = true;
status.textContent = `${params.routeHead()} — 방향키로 옮기는 중(뗄 때 한 걸음). ${params.curveHint()}`;
params.draw();
}
/** 뗌 — 옮겼으면 되돌리기 한 걸음(스냅 켬이면 맞춤) · 횡단 · 기울기 다시 · 결과 한 줄. */
function release(): void {
window.clearTimeout(timer);
window.clearInterval(timer);
const done = hold;
hold = null;
if (!done?.moved) return;
const end = params.state().planned[done.index] ?? done.start;
params.commit();
void params.crossPreview.refresh();
params.slopeRefresh();
const report = nudgeReport(done.before, params.state().planned, done.index, done.start, end);
const message = nudgeMessage(report, params.stations() !== null);
status.textContent = `${params.routeHead()} — ${message} ${params.curveHint()}`;
params.draw();
}
const detach = (): void => {
window.removeEventListener("keydown", onKeyDown, true);
window.removeEventListener("keyup", onKeyUp, true);
window.removeEventListener("blur", release);
};
function onKeyDown(event: KeyboardEvent): void {
if (!canvas.isConnected) return detach();
if (!isArrowKey(event.key) || !lastPointerWithin(".b05-routeedit")) return; // B06 겹침 평면도 같음(436)
const target = event.target as HTMLElement | null;
if (target?.closest?.("input, select, textarea, [contenteditable='true']")) return;
// 평면이 삼킴 — 종단도(▲▼ 계획고 · `defaultPrevented` 면 비킴) · 횡단 조정창으로 안 감
event.preventDefault();
event.stopPropagation();
if (event.repeat || hold || dragging() || params.measureMode()) return; // 반복은 아래 타이머
const state = params.state();
const index = state.picked;
const node = state.planned[index];
if (!node || state.apexLock[index] === true) return;
const now: Hold = {
key: event.key,
shift: event.shiftKey,
index,
before: state.planned.map(([x, y]): Vertex => [x, y]),
start: [node[0], node[1]],
raw: null,
off: params.offStation(),
moved: false,
};
hold = now;
step(now);
// 누르고 있으면 종단 편집과 같은 속도(0.5초 뒤 초당 10회) — OS 키 반복은 기기마다 갈림
timer = window.setTimeout(() => {
timer = window.setInterval(() => hold === now && step(now), HOLD_INTERVAL_MS);
}, HOLD_DELAY_MS);
}
function onKeyUp(event: KeyboardEvent): void {
if (!canvas.isConnected) return detach();
if (!hold || event.key !== hold.key) return;
event.preventDefault();
event.stopPropagation();
release();
}
window.addEventListener("keydown", onKeyDown, true);
window.addEventListener("keyup", onKeyUp, true);
window.addEventListener("blur", release);
}