/* ============================================================================= * B05_Profile_UI_RouteEdit_CurveBar.ts * 곡선 조작 패널의 **배선** — 어느 꺾임점을 만질지 정하고, 칸에서 들어온 값을 편집값에 * 옮겨 적는다. 패널을 그리고 자리를 잡는 일은 `_Label` 몫이다. * * `B05_Profile_UI_RouteEdit.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12). 본문 로직과 * 수치는 그대로이고, 모달 클로저가 쥐고 있던 값만 `state()` 로 받는다. * * **R 과 곡선 길이는 한 쌍**(L = R·Δ) — 어느 쪽으로 들어와도 **반지름 한 값**으로 바꿔 * 들고 간다. 두 벌로 두면 교각이 바뀔 때 서로 어긋난다(`_Edits.ts` 설명 참고). * ========================================================================== */ import type { EditedCurve, EditedNode, Vertex } from "./B05_Profile_UI_RouteEdit_Curve"; import { curveOptionalAt, deflectionRad, radiusFloorM, type CurveLock, } from "./B05_Profile_UI_RouteEdit_Edits"; import { centerDirectionOf, createCurveLabel, type CurveLabel, } from "./B05_Profile_UI_RouteEdit_Label"; /** 패널이 만지는 편집값 한 벌 — 모달이 쥔 배열을 그대로 건네받는다. */ export interface CurveBarState { picked: number; planned: Vertex[]; nodeInfo: EditedNode[]; curveInfo: EditedCurve[]; curveOn: boolean[]; curveRadius: Array; curveLock: CurveLock[]; curveArc: Array; /** 교각점 자리를 못 박은 꺾임점(2026-09-12 사용자 지시 ①). */ apexLock: boolean[]; /** 못 넘는 하한(m). 0이면 제한 없음(계획서 0-9 ④). */ limitRadiusM: number; limitArcM: number; } export interface CurveBarParams { canvas: HTMLCanvasElement; state: () => CurveBarState; /** 그리기 좌표로 옮긴다. 돌린 지도에서는 **돌린 뒤 자리**를 줘야 패널이 노드 옆에 붙는다. */ toScreen: (vertex: Vertex) => [number, number]; /** 한 번의 편집을 마무리한다 — 다시 그리고 되돌리기에 쌓는다. */ applyEdit: (message: string) => void; /** 고른 꺾임점을 푼다 — 닫기 단추와 「빈 곳 누르기」가 부른다(계획서 0-9 ㉖). */ onUnselect: () => void; } export interface CurveBar { label: CurveLabel; /** 고른 자리에 맞춰 패널을 옮겨 그린다. */ sync: () => void; } export function createCurveBar(params: CurveBarParams): CurveBar { const label = createCurveLabel({ onClose: () => params.onUnselect(), onRadius: (value) => { const { picked, curveRadius } = params.state(); if (picked < 0) return; curveRadius[picked] = value; // 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다. params.applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다."); }, onArcLength: (value) => { const { picked, nodeInfo, curveArc, curveRadius } = 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; params.applyEdit( value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.", ); }, 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 } = params.state(); if (picked < 0) return; apexLock[picked] = locked; // 자리는 안 바뀌었지만 잡히는 것이 달라졌다 — 상태줄과 되돌리기에 한 걸음으로 남긴다. params.applyEdit(locked ? "교각점을 고정했습니다." : "교각점 고정을 풀었습니다."); }, onCurveOn: (on) => { const { picked, curveOn } = params.state(); if (picked < 0) return; curveOn[picked] = on; params.applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다."); }, }); /** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */ function sync(): void { const { picked, planned, nodeInfo, curveInfo, curveOn, curveRadius, curveLock, apexLock, limitRadiusM, limitArcM, } = params.state(); if (!(picked > 0 && picked < planned.length - 1)) { label.hide(); return; } const pickedCurve = curveInfo.find((entry) => entry.node_first === picked); const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null; const innerAngle = nodeInfo[picked]?.inner_angle_deg ?? null; const optional = curveOptionalAt(innerAngle); const deflection = deflectionRad(innerAngle); const rect = params.canvas.getBoundingClientRect(); const [screenX, screenY] = params.toScreen(planned[picked]); label.show({ seat: picked, // 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다. at: [screenX + rect.left, screenY + rect.top], // 넘어가도 되는 테두리 = **지도 칸**(하단 정보행 위까지). 밖으로 나가면 지금 무엇을 // 고치는지 모달 안에서 안 보인다(2026-09-12 사용자 지적 ⑨). bounds: { left: rect.left + 8, top: rect.top + 8, right: rect.right - 8, bottom: rect.bottom - 8, }, 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 }; }