Files
Aislo/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts
T
eomsangdonandClaude Opus 5.5 4fefd219c2 feat(B05 평면 형상 제어): 하한 검사 applyEdit 한 곳 · 곡선 삭제 155° 경고 · 놓을 때 맞춤 보고 · 스냅 즉시 계산 줄 · 넣은 R · 고정 곡선표 저장 (430)
- 하한 검사 = 모든 평면 조작이 applyEdit 에서 지난 걸음과 견줌 · 새로 깨면 그 걸음으로 되돌림 · 까닭 상태줄(끼우기 · 지우기 · 놓기 · 방향키 · 곡선 칸) · 손잡이 끌기도 걸음마다 검사
- 곡선 삭제 = 내각 155° 미만이면 막지 않고 경고(상태줄 · 알림)
- 끌기 · 손잡이 놓을 때 스냅 켬이면 「함께 밀린 교점 · 못 맞춘 곡선」 보고
- 스냅 끔 · 간격 바꿈 = 계산 줄에 초안 바로(tellDraft)
- R · 길이 고정 · 넣은 R = 곡선표 edits 칸(짝 B05_Profile_Route_NodeEdits.py ↔ flattenServerPlan) · 다시 열면 넣은 값으로
- 상태줄 = 겹친 노드(0.5 m 안) · 못 그린 곡선 표시
- 시험 test_430_route_edit_rules · 배선 바뀐 시험 둘(352-3 · 390-4) · 398-2 도우미 알림 가짜

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-18 16:07:48 +09:00

266 lines
12 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_RouteEdit_CurveBar.ts
* 곡선 조작 패널의 **배선** — 어느 꺾임점을 만질지 정하고, 칸에서 들어온 값을 편집값에
* 옮겨 적는다. 패널을 그리고 자리를 잡는 일은 `_Label` 몫이다.
*
* `B05_Profile_UI_RouteEdit.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12). 본문 로직과
* 수치는 그대로이고, 모달 클로저가 쥐고 있던 값만 `state()` 로 받는다.
*
* **R 과 곡선 길이는 한 쌍**(L = R·Δ) — 어느 쪽으로 들어와도 **반지름 한 값**으로 바꿔
* 들고 간다. 두 벌로 두면 교각이 바뀔 때 서로 어긋난다(`_Edits.ts` 설명 참고).
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import type { EditedCurve, EditedNode, Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve";
import {
CURVE_OPTIONAL_INNER_ANGLE_DEG,
curveOptionalAt,
deflectionRad,
radiusFloorM,
type CurveLock,
} from "./logic/B05_Profile_UI_RouteEdit_Edits";
import {
centerDirectionOf,
createCurveLabel,
type CurveLabel,
} from "./B05_Profile_UI_RouteEdit_Label";
import { fitNote } from "./logic/B05_Profile_RouteEdit_KeyNudge";
import { offStationMcs } from "./logic/B05_Profile_RouteEdit_StationFit";
/** 패널이 만지는 편집값 한 벌 — 모달이 쥔 배열을 그대로 건네받는다. */
export interface CurveBarState {
picked: number;
planned: Vertex[];
nodeInfo: EditedNode[];
curveInfo: EditedCurve[];
curveOn: boolean[];
curveRadius: Array<number | null>;
curveLock: CurveLock[];
curveArc: Array<number | null>;
/** 교각점 자리를 못 박은 꺾임점(2026-09-12 사용자 지시 ①). */
apexLock: boolean[];
/** 못 넘는 하한(m). 0이면 제한 없음(계획서 0-9 ④). */
limitRadiusM: number;
limitArcM: number;
/** 그린 계획노선(곡선 편 정점) — 측점 밖 곡선 MC 셈(408-1). */
line: Vertex[];
/** 측점 스냅 켬이면 맞춤 간격 · 측점 간격(m) · 끔이면 null(맞춤 없음 = 보고 없음). */
snap: { stepM: number; stationM: number } | null;
}
export interface CurveBarParams {
canvas: HTMLCanvasElement;
/** 패널이 앉을 칸(편집기 뿌리 · 345-3). */
container: HTMLElement;
state: () => CurveBarState;
/** 그리기 좌표로 옮긴다. 돌린 지도에서는 **돌린 뒤 자리**를 줘야 패널이 노드 옆에 붙는다. */
toScreen: (vertex: Vertex) => [number, number];
/** 한 번의 편집을 마무리한다 — 다시 그리고 되돌리기에 쌓는다. */
applyEdit: (message: string) => void;
/** 고른 꺾임점을 푼다 — 닫기 단추와 「빈 곳 누르기」가 부른다(계획서 0-9 ㉖). */
onUnselect: () => void;
}
export interface CurveBar {
label: CurveLabel;
/** 고른 자리에 맞춰 패널을 옮겨 그린다. */
sync: () => void;
/** 끌기 · 손잡이 놓을 때 맞춤 결과를 상태줄 `message` 뒤에(430 · 스냅 끔이면 그대로). */
noteFit: (message: string, before: Vertex[], picked: number) => void;
}
export function createCurveBar(params: CurveBarParams): CurveBar {
const label = createCurveLabel(
{
onClose: () => params.onUnselect(),
onRadius: (value) => {
const { picked, curveRadius, planned } = params.state();
if (picked < 0) return;
curveRadius[picked] = value;
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
const message = value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.";
params.applyEdit(message);
noteResult(message, "반지름", planned);
},
onArcLength: (value) => {
const { picked, nodeInfo, curveArc, curveRadius, planned } = params.state();
if (picked < 0) return;
// 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함).
// 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다.
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
curveArc[picked] = value;
curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null;
const message =
value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.";
params.applyEdit(message);
noteResult(message, "곡선 길이", planned);
},
onLock: (lock) => {
const { picked, nodeInfo, curveArc, curveRadius, curveLock } = params.state();
if (picked < 0) return;
curveLock[picked] = lock;
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
const shown = curveRadius[picked] ?? nodeInfo[picked]?.radius_m ?? null;
// 길이를 붙들려면 지금 길이를 적어 둬야 한다 — 뒤에 교각이 바뀌면 이 값으로 R 을 다시 잡는다.
if (lock === "arc") {
curveArc[picked] = shown !== null && deflection > 1e-9 ? shown * deflection : null;
}
// R 을 붙들 때 칸이 비어 있으면 지금 그려진 R 을 적어 둔다(자동 상태를 그대로 못 박음).
if (lock === "radius" && curveRadius[picked] === null) curveRadius[picked] = shown;
params.applyEdit(
lock === "radius"
? "반지름을 고정했습니다."
: lock === "arc"
? "곡선 길이를 고정했습니다."
: "고정을 풀었습니다.",
);
},
onApexLock: (locked) => {
const { picked, apexLock, planned } = params.state();
if (picked < 0) return;
apexLock[picked] = locked;
const name = picked === 0 ? "시점" : picked === planned.length - 1 ? "종점" : "교각점";
// 자리는 안 바뀌었지만 잡히는 것이 달라졌다 — 상태줄과 되돌리기에 한 걸음으로 남긴다.
params.applyEdit(locked ? `${name}을 고정했습니다.` : `${name} 고정을 풀었습니다.`);
},
onCurveOn: (on) => {
const { picked, curveOn, nodeInfo } = params.state();
if (picked < 0) return;
curveOn[picked] = on;
// 내각 155° 미만 = 곡선을 둬야 하는 자리(별표2 Ⅰ.2.다.(1)) — 막지 않고 경고(430).
const inner = nodeInfo[picked]?.inner_angle_deg ?? null;
const warn =
!on && inner !== null && !curveOptionalAt(inner)
? ` — 내각 ${inner.toFixed(1)}° < ${CURVE_OPTIONAL_INNER_ANGLE_DEG}° 곡선 자리(경고)`
: "";
params.applyEdit(on ? "곡선을 넣었습니다." : `곡선을 지웠습니다${warn}.`);
if (warn) showToast(`곡선을 지웠습니다${warn}.`, "warning", 5000);
},
},
params.container,
);
/** 넣은 값을 다 못 그렸으면 상태줄 글을 넣은 값 → 그린 값 · 까닭으로(398-2 · 상태줄 두 줄 안) — 접선은
* 앞뒤 직선 짧은 쪽 절반까지(`buildEditedPolyline` · 서버 짝 같은 규칙)라 큰 R 은 조용히 눌렸다.
* 스냅 켬이면 맞춤이 민 교점 · 못 맞춘 곡선을 덧붙임(408-1 · `before` = 입력 전 교점). */
function noteResult(message: string, name: "반지름" | "곡선 길이", before: Vertex[]): void {
const { picked, curveRadius, curveInfo, nodeInfo } = params.state();
const status = params.container.querySelector<HTMLElement>(".b05-routeedit__status");
if (!status) return;
const asked = curveRadius[picked];
const drawn = curveInfo.find((entry) => entry.node_first === picked);
const per = name === "곡선 길이" ? deflectionRad(nodeInfo[picked]?.inner_angle_deg) : 1;
const m = (value: number): string => `${Math.round(value * 10) / 10}m`;
const head =
asked === null || asked === undefined || !drawn || drawn.radius_m >= asked - 0.05
? message.replace(/\.$/, "")
: `${name} ${m(asked * per)} → ${m(drawn.radius_m * per)}(앞뒤 직선이 짧아 접선 자리 모자람)`;
status.textContent = (status.textContent ?? "").replace(
message,
`${head}${fitTail(before, picked, false)}.`,
);
}
/** 맞춤이 민 교점 · 못 맞춘 곡선 꼬리 — 스냅 끔이면 ""(`hand` = 고른 IP 를 손이 옮김). */
function fitTail(before: Vertex[], picked: number, hand: boolean): string {
const { curveInfo, planned, line, snap } = params.state();
if (!snap) return "";
const off = offStationMcs(line, curveInfo, snap.stepM);
return fitNote(before, planned, picked, off, snap.stationM, snap.stepM, hand);
}
/** 끌기 · 손잡이 놓을 때(430) — 상태줄 `message` 뒤에 맞춤 결과(`before` = 누르기 전 교점). */
function noteFit(message: string, before: Vertex[], picked: number): void {
const status = params.container.querySelector<HTMLElement>(".b05-routeedit__status");
const tail = fitTail(before, picked, true);
if (status && tail)
status.textContent = (status.textContent ?? "").replace(
message,
`${message.replace(/\.$/, "")}${tail}.`,
);
}
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없어 **고정 단추만** 낸다(계획서 22-5). */
function sync(): void {
const {
picked,
planned,
nodeInfo,
curveInfo,
curveOn,
curveRadius,
curveLock,
apexLock,
limitRadiusM,
limitArcM,
} = params.state();
if (!(picked >= 0 && picked < planned.length)) {
label.hide();
return;
}
const rect = params.canvas.getBoundingClientRect();
const bounds = {
left: rect.left + 8,
top: rect.top + 8,
right: rect.right - 8,
bottom: rect.bottom - 8,
};
const last = planned.length - 1;
if (picked === 0 || picked === last) {
const [ex, ey] = params.toScreen(planned[picked]);
const [nx, ny] = params.toScreen(planned[picked === 0 ? 1 : last - 1]);
label.show({
seat: picked,
endpoint: picked === 0 ? "start" : "end",
at: [ex + rect.left, ey + rect.top],
bounds,
// 노선에 직각으로 비켜 붙인다 — 노선 바깥쪽에는 시점 · 종점 이름표가 있다.
centerDirection: [-(ny - ey), nx - ex],
curveOn: false,
radiusShown: null,
arcLengthShown: null,
lock: null,
apexLocked: apexLock[picked] === true,
innerAngleDeg: null,
curveOptional: false,
});
return;
}
const pickedCurve = curveInfo.find((entry) => entry.node_first === picked);
// 칸 = 그린 R(398-2) — 넣은 값을 그대로 보이면 접선 자리에 눌린 것이 안 보임.
const shown = pickedCurve?.radius_m ?? curveRadius[picked] ?? null;
const innerAngle = nodeInfo[picked]?.inner_angle_deg ?? null;
const optional = curveOptionalAt(innerAngle);
const deflection = deflectionRad(innerAngle);
const [screenX, screenY] = params.toScreen(planned[picked]);
label.show({
seat: picked,
// 패널 자리는 **화면 좌표**로 넘긴다(칸 안 좌표로는 `_Label` 이 바꿔 적음).
at: [screenX + rect.left, screenY + rect.top],
// 넘어가도 되는 테두리 = **지도 칸**(하단 정보행 위까지). 밖으로 나가면 지금 무엇을
// 고치는지 모달 안에서 안 보인다(2026-09-12 사용자 지적 ⑨).
bounds,
centerDirection: pickedCurve
? centerDirectionOf(
params.toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]),
params.toScreen(pickedCurve.start),
params.toScreen(pickedCurve.end),
)
: null,
curveOn: curveOn[picked] !== false,
radiusShown: shown,
arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection,
lock: curveLock[picked] ?? null,
apexLocked: apexLock[picked] === true,
innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null,
curveOptional: optional,
// 칸이 막는 하한은 **그 자리 값**이다 — R 칸은 L 하한까지 환산해 함께 보고,
// 곡선을 생략해도 되는 자리(내각 155° 이상)는 L 하한을 안 건다(2026-09-12 확정).
limitRadiusM: radiusFloorM(innerAngle, limitRadiusM, limitArcM),
limitArcM: optional ? 0 : limitArcM,
});
}
return { label, sync, noteFit };
}