/* ============================================================================= * B05_Profile_UI_RouteEdit_Label.ts * 고른 꺾임점 옆에 뜨는 **곡선 조작 패널** — 반지름·곡선 길이를 보고 고치고 붙든다. * * 왜 옆인가(2026-09-07 사용자 지시 ③) — 예전에는 모달 **맨 아래 줄**이라 지금 고른 것이 * 지도 어디인지 눈으로 안 이어졌다. * * **R 과 곡선 길이는 한 쌍**(L = R·Δ, Δ 는 앞뒤 직선이 정하는 교각) — 한쪽을 고치면 다른 * 쪽이 따라온다. 「자동」 단추는 없다: 칸을 비우는 것이 곧 자동이다. 어느 쪽을 붙들지는 * **고정 단추**로 정한다(둘 다 붙들 수는 없다 — `_Edits.ts` 설명 참고). * * **자리**(2026-09-07 사용자 지시) * · 몸통은 `document.body` 에 `position: fixed` 로 띄운다 — 모달이 `overflow: hidden` 이라 * 안에 두면 가장자리에서 **잘린다**. 화면 밖으로도 넘어갈 수 있어야 한다. * · 자동 자리는 **곡선 중심의 반대쪽**, **16방위**로 잡는다(4방위는 대각 자리에서 곡선을 물었다). * · 머리를 잡아 **손으로 옮길 수 있다**. 옮긴 자리는 그 꺾임점을 보는 동안 유지되고, * 다른 꺾임점을 고르면 자동 자리로 돌아간다. * ========================================================================== */ import type { CurveLock } from "./B05_Profile_UI_RouteEdit_Edits"; /** 그 꺾임점의 **교각 Δ**(라디안) — 내각의 나머지. 곡선 길이 L = R·Δ 에 쓴다. */ export function deflectionRad(innerAngleDeg: number | null | undefined): number { if (innerAngleDeg === null || innerAngleDeg === undefined) return 0; return ((180 - innerAngleDeg) * Math.PI) / 180; } /** 곡선 중심이 있는 **화면 방향**(단위벡터) — 패널을 그 반대쪽에 붙이는 데 쓴다. * * 중심은 접선점 둘이 이루는 각의 이등분선 위에 있다. 접선점은 교각점에서 앞뒤 직선을 따라 * 뻗은 자리이므로, 두 방향의 단위벡터를 더하면 그대로 중심 쪽이다. 셋이 한 점이면 null. */ export function centerDirectionOf( apex: [number, number], start: [number, number], end: [number, number], ): [number, number] | null { const arm = (point: [number, number]): [number, number] => { const dx = point[0] - apex[0]; const dy = point[1] - apex[1]; const length = Math.hypot(dx, dy); return length <= 1e-9 ? [0, 0] : [dx / length, dy / length]; }; const [ux, uy] = arm(start); const [vx, vy] = arm(end); const sx = ux + vx; const sy = uy + vy; const length = Math.hypot(sx, sy); return length <= 1e-9 ? null : [sx / length, sy / length]; } /** 꺾임점과 패널 사이 여백(px) — 손잡이(접선점 네모)를 가리지 않을 만큼 띄운다. */ const GAP_PX = 40; /** 자동 자리를 고를 방위 수 — 16방위(22.5°마다). */ const COMPASS_STEPS = 16; export interface CurveLabelState { /** 몇 번째 꺾임점인지 — 0부터 센 자리. 표시는 +1 해서 낸다. */ seat: number; /** 그 꺾임점의 **화면(viewport) 좌표** px — 패널이 `position: fixed` 라서. */ at: [number, number]; /** **곡선 중심이 있는 쪽**(화면 기준 방향벡터). 패널은 이 반대쪽에 붙는다. */ centerDirection: [number, number] | null; curveOn: boolean; radiusShown: number | null; /** 곡선 길이(m) = R·Δ. 곡선이 없으면 null. */ arcLengthShown: number | null; lock: CurveLock; innerAngleDeg: number | null; } export interface CurveLabelHandlers { onRadius: (value: number | null) => void; onArcLength: (value: number | null) => void; onCurveOn: (on: boolean) => void; /** 무엇을 붙들지 바꿨다 — 같은 것을 다시 누르면 null(품). */ onLock: (lock: CurveLock) => void; } export interface CurveLabel { show: (state: CurveLabelState) => void; hide: () => void; /** 창을 닫을 때 — 몸통이 `document.body` 에 붙어 있어 스스로 안 사라진다. */ destroy: () => void; } /** 방향을 16방위 중 가장 가까운 것으로 맞춘다. */ function quantize(dx: number, dy: number): [number, number] { const step = (2 * Math.PI) / COMPASS_STEPS; const angle = Math.round(Math.atan2(dy, dx) / step) * step; return [Math.cos(angle), Math.sin(angle)]; } /** 가운데에서 그 방향으로 상자 가장자리까지의 거리 — 방위마다 다르다. */ function boxReach(dx: number, dy: number, width: number, height: number): number { const byX = Math.abs(dx) < 1e-9 ? Infinity : width / 2 / Math.abs(dx); const byY = Math.abs(dy) < 1e-9 ? Infinity : height / 2 / Math.abs(dy); return Math.min(byX, byY); } /** 패널을 만든다. 몸통은 `document.body` 에 붙어 모달 밖으로도 넘어간다. */ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { const root = document.createElement("div"); root.className = "b05-routeedit__label"; root.hidden = true; root.innerHTML = `
칸을 비우면 자동`; document.body.append(root); const head = root.querySelector(".b05-routeedit__label-head")!; const seatText = root.querySelector(".b05-routeedit__curve-label")!; const toggle = root.querySelector('[data-act="curve-toggle"]')!; const radius = root.querySelector(".b05-routeedit__curve-radius")!; const arc = root.querySelector(".b05-routeedit__curve-arc")!; const lockRadius = root.querySelector('[data-act="lock-radius"]')!; const lockArc = root.querySelector('[data-act="lock-arc"]')!; const info = root.querySelector(".b05-routeedit__curve-info")!; // 패널 위에서 누른 것이 캔버스로 새어 나가면 노드가 딸려 움직인다. for (const type of ["pointerdown", "dblclick", "contextmenu", "wheel"] as const) { root.addEventListener(type, (event) => event.stopPropagation()); } let curveOn = true; let lock: CurveLock = null; let seat = -1; /** 손으로 옮긴 자리 — 꺾임점 기준 어긋남(px). 다른 꺾임점을 고르면 지운다. */ let manual: [number, number] | null = null; let anchor: [number, number] = [0, 0]; const numberOf = (input: HTMLInputElement): number | null => { const value = Number(input.value); return input.value.trim() !== "" && Number.isFinite(value) && value > 0 ? value : null; }; radius.addEventListener("change", () => handlers.onRadius(numberOf(radius))); arc.addEventListener("change", () => handlers.onArcLength(numberOf(arc))); toggle.addEventListener("click", () => handlers.onCurveOn(!curveOn)); lockRadius.addEventListener("click", () => handlers.onLock(lock === "radius" ? null : "radius")); lockArc.addEventListener("click", () => handlers.onLock(lock === "arc" ? null : "arc")); // ── 머리를 잡아 옮기기 — 곡선을 가리면 손으로 치울 수 있어야 한다 ── let dragFrom: { x: number; y: number; left: number; top: number } | null = null; head.addEventListener("pointerdown", (event) => { if ((event.target as HTMLElement).closest("button")) return; // 단추는 단추대로. dragFrom = { x: event.clientX, y: event.clientY, left: root.offsetLeft, top: root.offsetTop, }; head.setPointerCapture(event.pointerId); event.preventDefault(); }); head.addEventListener("pointermove", (event) => { if (!dragFrom) return; const left = dragFrom.left + event.clientX - dragFrom.x; const top = dragFrom.top + event.clientY - dragFrom.y; root.style.left = `${Math.round(left)}px`; root.style.top = `${Math.round(top)}px`; // 꺾임점 기준으로 기억한다 — 지도를 옮기거나 확대해도 같은 자리에 따라온다. manual = [left - anchor[0], top - anchor[1]]; }); const stopDrag = (event: PointerEvent): void => { if (head.hasPointerCapture(event.pointerId)) head.releasePointerCapture(event.pointerId); dragFrom = null; }; head.addEventListener("pointerup", stopDrag); head.addEventListener("pointercancel", stopDrag); /** 자동 자리 — 곡선 중심의 반대쪽, 16방위. 손으로 옮겼으면 그 어긋남을 얹는다. */ function place(state: CurveLabelState): void { const width = root.offsetWidth; const height = root.offsetHeight; const [nx, ny] = state.at; const away = state.centerDirection ? quantize(-state.centerDirection[0], -state.centerDirection[1]) : ([1, 0] as [number, number]); const distance = GAP_PX + boxReach(away[0], away[1], width, height); anchor = [nx + away[0] * distance - width / 2, ny + away[1] * distance - height / 2]; const left = anchor[0] + (manual ? manual[0] : 0); const top = anchor[1] + (manual ? manual[1] : 0); root.style.left = `${Math.round(left)}px`; root.style.top = `${Math.round(top)}px`; } return { show(state) { if (state.seat !== seat) { seat = state.seat; manual = null; // 다른 꺾임점이면 자동 자리부터 다시. } curveOn = state.curveOn; lock = state.lock; root.hidden = false; seatText.textContent = `${state.seat + 1}번째 꺾임점`; toggle.textContent = state.curveOn ? "곡선 지우기" : "곡선 넣기"; radius.disabled = !state.curveOn; arc.disabled = !state.curveOn; lockRadius.disabled = !state.curveOn; lockArc.disabled = !state.curveOn; lockRadius.classList.toggle("is-on", lock === "radius"); lockArc.classList.toggle("is-on", lock === "arc"); radius.value = state.radiusShown === null ? "" : String(Math.round(state.radiusShown * 10) / 10); arc.value = state.arcLengthShown === null ? "" : String(Math.round(state.arcLengthShown * 10) / 10); const inner = state.innerAngleDeg; const held = lock === "radius" ? "반지름 고정" : lock === "arc" ? "곡선 길이 고정" : "고정 없음"; info.textContent = state.curveOn ? `${held}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}` : "곡선 없음 — 직선이 그대로 꺾입니다"; place(state); // 글자가 바뀌면 상자 높이가 한 박자 늦게 자란다 — 다음 그림 직전에 한 번 더 맞춘다. requestAnimationFrame(() => { if (!root.hidden) place(state); }); }, hide() { root.hidden = true; }, destroy() { root.remove(); }, }; }