diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 09a1f611..8b644721 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -42,6 +42,12 @@ import { createCurveLabel, deflectionRad, } from "./B05_Profile_UI_RouteEdit_Label"; +import { + applyArcLocks, + curveSummary, + flattenServerPlan, + type CurveLock, +} from "./B05_Profile_UI_RouteEdit_Edits"; import { bindHistoryControls, createRouteEditHistory, @@ -128,6 +134,10 @@ export async function openRouteEditModal( /** 꺾임점마다의 편집값 — 곡선을 둘지, 반지름을 못박을지(2026-09-07 사용자 지시). */ let curveOn: boolean[] = []; let curveRadius: Array = []; + /** 붙들어 둔 값 — 무엇을 고정할지와, 길이를 고정했을 때의 그 길이(m). + * 노드를 옮기면 교각이 바뀌어 R 과 길이 중 하나는 반드시 따라 움직인다(`_Edits.ts`). */ + let curveLock: CurveLock[] = []; + let curveArc: Array = []; /** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */ let picked = -1; /** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */ @@ -148,6 +158,7 @@ export async function openRouteEditModal( closed = true; window.removeEventListener("resize", resize); historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 닫힌 뒤에도 산다. + curveLabelBox.destroy(); // 패널은 `document.body` 에 붙어 있어 스스로 안 사라진다. overlay.remove(); }; overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", close); @@ -299,7 +310,14 @@ export async function openRouteEditModal( /** 잡기 — 셈은 `_RouteEdit_Input` 몫이고, 여기서는 지금 상태를 건네준다. */ const handleAt = (px: number, py: number): { node: number; end: "start" | "end" } | null => - handleAtScreen(curveInfo, toScreen, px, py, NODE_HIT_PX + 2); + handleAtScreen( + // 붙들어 둔 곡선은 손잡이로도 안 바뀐다 — 끌면 R 이 바뀌기 때문(사용자 지시 5). + curveInfo.filter((entry) => (curveLock[entry.node_first] ?? null) === null), + toScreen, + px, + py, + NODE_HIT_PX + 2, + ); const nodeAt = (px: number, py: number): number => nodeAtScreen(planned, toScreen, px, py, NODE_HIT_PX); const segmentAt = (px: number, py: number): number => @@ -326,35 +344,29 @@ export async function openRouteEditModal( * 지금은 서버와 **같은 규칙**(`buildEditedPolyline` 짝)으로 즉시 다시 만든다. [확인] 때 * 서버가 정본으로 다시 내는 것은 그대로다. */ function markEdited(): void { + // 길이를 붙든 자리는 교각이 바뀌었을 수 있다 — 그리기 전에 R 부터 다시 잡는다. + applyArcLocks(planned, curveLock, curveArc, curveRadius); const built = buildEditedPolyline(planned, curveOn, curveRadius, minRadiusM); plannedLine = built.vertices; curveInfo = built.curves; nodeInfo = built.nodes; } - /** 상태줄 꼬리 — 곡선 기준과 위반 수를 알린다. */ - function curveHint(): string { - const off = curveOn.filter( - (on, index) => !on && index > 0 && index < planned.length - 1, - ).length; - const forced = curveRadius.filter((value) => value !== null).length; - const edits = [off ? `곡선 지움 ${off}곳` : "", forced ? `R 지정 ${forced}곳` : ""] - .filter(Boolean) - .join(" · "); - if (!nodeInfo.length) { - const base = minRadiusM ? `곡선 기준 R ${minRadiusM}m — [확인] 때 반영` : ""; - return edits ? `${base}${base ? " · " : ""}${edits}` : base; - } - const bad = nodeInfo.filter((node) => node.violations.length).length; - const curves = curveInfo.length || nodeInfo.filter((node) => node.radius_m !== null).length; - return ( - `곡선 ${curves}곳(하한 R ${minRadiusM}m)` + - `${bad ? ` · 기준 미달 ${bad}곳` : ""}${edits ? ` · ${edits}` : ""}` - ); - } + /** 상태줄 꼬리 — 셈은 `_Edits` 몫. */ + const curveHint = (): string => + curveSummary({ + nodeCount: planned.length, + curveOn, + curveRadius, + curveLock, + curveCount: curveInfo.length, + violationCount: nodeInfo.filter((node) => node.violations.length).length, + minRadiusM, + fresh: nodeInfo.length === 0, + }); // ── 곡선 라벨 — 고른 꺾임점 옆(곡선 중심 반대쪽)에 뜬다. 그리기는 `_Label` 몫 ── - const curveLabelBox = createCurveLabel(canvas.parentElement!, { + const curveLabelBox = createCurveLabel({ onRadius: (value) => { if (picked < 0) return; curveRadius[picked] = value; @@ -366,9 +378,29 @@ export async function openRouteEditModal( // 곡선 길이 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; applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다."); }, + onLock: (lock) => { + 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; + applyEdit( + lock === "radius" + ? "반지름을 고정했습니다." + : lock === "arc" + ? "곡선 길이를 고정했습니다." + : "고정을 풀었습니다.", + ); + }, onCurveOn: (on) => { if (picked < 0) return; curveOn[picked] = on; @@ -382,13 +414,15 @@ export async function openRouteEditModal( curveLabelBox.hide(); return; } - const forced = curveRadius[picked]; const pickedCurve = curveInfo.find((entry) => entry.node_first === picked); - const shown = forced ?? pickedCurve?.radius_m ?? null; + const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null; const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg); + const rect = canvas.getBoundingClientRect(); + const [screenX, screenY] = toScreen(planned[picked]); curveLabelBox.show({ seat: picked, - at: toScreen(planned[picked]), + // 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다 — 모달 밖으로 넘어가도 안 잘린다. + at: [screenX + rect.left, screenY + rect.top], centerDirection: pickedCurve ? centerDirectionOf( toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]), @@ -396,13 +430,11 @@ export async function openRouteEditModal( toScreen(pickedCurve.end), ) : null, - canvas: { width: view.width, height: view.height }, curveOn: curveOn[picked] !== false, radiusShown: shown, arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection, - forced: forced !== null, + lock: curveLock[picked] ?? null, innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null, - minRadiusM, }); } @@ -418,7 +450,7 @@ export async function openRouteEditModal( /** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */ function snapshotNow(): RouteEditSnapshot { - return { planned, curveOn, curveRadius, picked }; + return { planned, curveOn, curveRadius, curveLock, curveArc, picked }; } const historyControls = bindHistoryControls({ @@ -428,6 +460,8 @@ export async function openRouteEditModal( planned = snapshot.planned; curveOn = snapshot.curveOn; curveRadius = snapshot.curveRadius; + curveLock = snapshot.curveLock; + curveArc = snapshot.curveArc; picked = snapshot.picked; applyEdit(message, false); // 되살리는 것은 새 걸음이 아니다. }, @@ -526,6 +560,8 @@ export async function openRouteEditModal( // 편집값도 같은 자리에 끼워 넣는다 — 안 그러면 뒤 노드의 R·켬끔이 한 칸씩 밀린다. curveOn.splice(segment + 1, 0, true); curveRadius.splice(segment + 1, 0, null); + curveLock.splice(segment + 1, 0, null); + curveArc.splice(segment + 1, 0, null); picked = segment + 1; applyEdit("새 노드를 넣었습니다(직선 추가)."); }); @@ -542,6 +578,8 @@ export async function openRouteEditModal( planned.splice(index, 1); curveOn.splice(index, 1); curveRadius.splice(index, 1); + curveLock.splice(index, 1); + curveArc.splice(index, 1); picked = -1; applyEdit("노드를 지웠습니다(직선 삭제)."); }); @@ -609,48 +647,15 @@ export async function openRouteEditModal( // (2026-09-06 사용자 지시: 노드를 제어해 계획노선을 고친다). const nodes = plan.nodes ?? []; minRadiusM = plan.min_radius_m ?? 0; - // 곡선 성분을 **편집할 수 있는 꼴로 펴 둔다**(2026-09-07). - // - // 서버는 처음 만들 때 이어진 꺾임을 한 곡선으로 묶는다 — 그 곡선의 교각점은 앞뒤 직선을 - // 늘려 만나는 자리라 **원본 꺾임점 중 어느 것도 아니다**. 그런데 편집은 「꺾임점 하나 = - // 곡선 하나」로 표현되므로, 묶인 곡선을 그대로 두면 한 번만 손대도 그 묶음이 낱개로 - // 흩어지고 **맞춰 둔 반지름이 전부 법정 하한으로 되돌아간다**(실측: R 12~199m → 전부 12m). - // - // 그래서 **묶인 구간을 그 교각점 하나로 갈아 끼운다** — 안쪽 꺾임점은 그 곡선이 대신하므로 - // 뺀다. 앞뒤 직선과 반지름이 그대로라 **그려지는 선은 똑같고**, 이제 손대도 안 흩어진다. - const curves = plan.curves ?? []; - const replaced = new Map(); - const dropped = new Set(); - curves.forEach((curve) => { - replaced.set(curve.node_first, curve); - for (let index = curve.node_first + 1; index <= curve.node_last; index += 1) { - dropped.add(index); - } - }); - planned = []; - nodeInfo = []; - curveOn = []; - curveRadius = []; - curveInfo = []; - nodes.forEach((node, index) => { - if (dropped.has(index)) return; - const curve = replaced.get(index); - const at: Vertex = curve ? [curve.apex[0], curve.apex[1]] : [node.x, node.y]; - const seat = planned.length; - planned.push(at); - nodeInfo.push({ - radius_m: curve ? curve.radius_m : node.radius_m, - inner_angle_deg: curve ? curve.inner_angle_deg : node.inner_angle_deg, - violations: curve ? (curve.violations ?? []) : (node.violations ?? []), - }); - // 서버가 **곡선을 안 둔 자리는 「곡선 없음」으로 연다**(2026-09-07). 전부 켬으로 열면 - // 아무것도 안 만지고 [확인]만 눌러도 그 자리에 곡선이 새로 생겨 노선이 조용히 바뀐다 - // (서버의 편집 갈래는 켜진 자리마다 원호를 끼우기 때문). 내각 179° 이상인 자리가 그렇다. - curveOn.push(curve !== undefined); - // 서버가 고른 반지름을 **그대로 들고 간다** — 안 그러면 편집 한 번에 하한으로 눌린다. - curveRadius.push(curve ? Math.round(curve.radius_m * 100) / 100 : null); - if (curve) curveInfo.push({ ...curve, node_first: seat, node_last: seat }); - }); + // 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다). + const flat = flattenServerPlan(nodes, plan.curves ?? []); + planned = flat.planned; + nodeInfo = flat.nodes; + curveInfo = flat.curves; + curveOn = flat.curveOn; + curveRadius = flat.curveRadius; + curveLock = flat.curveLock; + curveArc = flat.curveArc; picked = -1; // 여기가 [초기화]가 돌아갈 자리다 — 창을 연 그대로. history = createRouteEditHistory(snapshotNow()); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts new file mode 100644 index 00000000..a21e6987 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts @@ -0,0 +1,160 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Edits.ts + * 꺾임점마다의 **편집값**을 다루는 순수 함수 — 잠금과 상태줄 요약. + * + * **잠금이 왜 필요한가**(2026-09-07 사용자 지시) — 노드를 옮기면 앞뒤 직선의 교각(Δ)이 + * 바뀐다. 반지름 R 과 곡선 길이 L 은 **L = R·Δ** 로 묶여 있으므로, 한쪽을 붙들면 다른 쪽은 + * 반드시 따라 움직인다. 둘 다 붙들 수는 없다(그러면 Δ 를 못 바꾼다). 그래서 잠금은 셋 중 하나다. + * + * · `null` — 자동. R 은 법정 하한을 쓰고 L 은 따라온다. + * · `"radius"` — **R 고정**. 노드를 옮겨도 R 이 그대로고 L 이 바뀐다. + * · `"arc"` — **곡선 길이 고정**. 노드를 옮기면 R 을 L/Δ 로 다시 잡는다. + * + * ⚠ 접선 자리가 모자라면 그리기 단계에서 R 을 줄이는 것은 그대로다(`buildEditedPolyline`). + * 그것은 **그려지는 값**만 줄이고 잠가 둔 값은 안 건드린다 — 자리를 넓히면 되돌아온다. + * ========================================================================== */ + +import { + innerAngleDeg, + type EditedCurve, + type EditedNode, + type Vertex, +} from "./B05_Profile_UI_RouteEdit_Curve"; +import { deflectionRad } from "./B05_Profile_UI_RouteEdit_Label"; + +/** 무엇을 붙들고 있나. */ +export type CurveLock = "radius" | "arc" | null; + +/** + * **곡선 길이를 잠근 자리**의 반지름을 지금 교각에 맞춰 다시 잡는다(`curveRadius` 를 고침). + * + * 노드를 옮길 때마다 부른다. 잠그지 않았거나 R 을 잠근 자리는 손대지 않는다. + * 교각이 0 에 가까우면(거의 직선) 길이를 지킬 방법이 없으므로 그대로 둔다. + */ +export function applyArcLocks( + planned: Vertex[], + curveLock: CurveLock[], + curveArc: Array, + curveRadius: Array, +): void { + for (let seat = 1; seat < planned.length - 1; seat += 1) { + if (curveLock[seat] !== "arc") continue; + const length = curveArc[seat]; + if (length === null || length === undefined) continue; + const inner = innerAngleDeg(planned[seat - 1], planned[seat], planned[seat + 1]); + const deflection = deflectionRad(inner); + if (deflection <= 1e-9) continue; + curveRadius[seat] = length / deflection; + } +} + +export interface CurveSummaryInput { + nodeCount: number; + curveOn: boolean[]; + curveRadius: Array; + curveLock: CurveLock[]; + /** 그려 낸 곡선 수 — 아직 안 그렸으면 0. */ + curveCount: number; + /** 법정 기준을 못 맞춘 자리 수. */ + violationCount: number; + minRadiusM: number; + /** 아직 한 번도 안 그렸나(막 열었을 때). */ + fresh: boolean; +} + +/** 상태줄 꼬리 — 곡선 수·기준 미달·사용자가 손댄 자리를 한 줄로. */ +export function curveSummary(input: CurveSummaryInput): string { + const off = input.curveOn.filter( + (on, index) => !on && index > 0 && index < input.nodeCount - 1, + ).length; + const forced = input.curveRadius.filter((value) => value !== null).length; + const locked = input.curveLock.filter((lock) => lock !== null).length; + const edits = [ + off ? `곡선 지움 ${off}곳` : "", + forced ? `R 지정 ${forced}곳` : "", + locked ? `고정 ${locked}곳` : "", + ] + .filter(Boolean) + .join(" · "); + if (input.fresh) { + const base = input.minRadiusM ? `곡선 기준 R ${input.minRadiusM}m — [확인] 때 반영` : ""; + return edits ? `${base}${base ? " · " : ""}${edits}` : base; + } + return ( + `곡선 ${input.curveCount}곳(하한 R ${input.minRadiusM}m)` + + `${input.violationCount ? ` · 기준 미달 ${input.violationCount}곳` : ""}` + + `${edits ? ` · ${edits}` : ""}` + ); +} + +/** 서버가 준 노드·곡선을 **편집할 수 있는 꼴**로 편 결과. */ +export interface FlattenedPlan { + planned: Vertex[]; + nodes: EditedNode[]; + curves: EditedCurve[]; + curveOn: boolean[]; + curveRadius: Array; + curveLock: CurveLock[]; + curveArc: Array; +} + +interface ServerNode { + x: number; + y: number; + radius_m: number | null; + inner_angle_deg: number | null; + violations?: string[]; +} + +/** + * 서버가 준 노드·곡선을 **꺾임점 하나 = 곡선 하나**로 편다(2026-09-07). + * + * 서버는 처음 만들 때 이어진 꺾임을 한 곡선으로 묶는다 — 그 곡선의 교각점은 앞뒤 직선을 + * 늘려 만나는 자리라 **원본 꺾임점 중 어느 것도 아니다**. 묶인 채로 두면 한 번만 손대도 + * 묶음이 낱개로 흩어지고 **맞춰 둔 반지름이 전부 법정 하한으로 되돌아간다**(실측: R + * 12~199m → 전부 12m). 그래서 **묶인 구간을 그 교각점 하나로 갈아 끼우고** 안쪽 꺾임점은 + * 뺀다. 앞뒤 직선과 반지름이 그대로라 **그려지는 선은 똑같다**. + * + * ⚠ 서버가 **곡선을 안 둔 자리는 「곡선 없음」으로 연다**. 전부 켬으로 열면 아무것도 안 + * 만지고 [확인]만 눌러도 그 자리에 곡선이 새로 생겨 노선이 조용히 바뀐다(서버의 편집 + * 갈래는 켜진 자리마다 원호를 끼운다). 내각 179° 이상인 자리가 그렇다. + */ +export function flattenServerPlan(nodes: ServerNode[], curves: EditedCurve[]): FlattenedPlan { + const replaced = new Map(); + const dropped = new Set(); + curves.forEach((curve) => { + replaced.set(curve.node_first, curve); + for (let index = curve.node_first + 1; index <= curve.node_last; index += 1) { + dropped.add(index); + } + }); + + const out: FlattenedPlan = { + planned: [], + nodes: [], + curves: [], + curveOn: [], + curveRadius: [], + curveLock: [], + curveArc: [], + }; + nodes.forEach((node, index) => { + if (dropped.has(index)) return; + const curve = replaced.get(index); + const seat = out.planned.length; + out.planned.push(curve ? [curve.apex[0], curve.apex[1]] : [node.x, node.y]); + out.nodes.push({ + radius_m: curve ? curve.radius_m : node.radius_m, + inner_angle_deg: curve ? curve.inner_angle_deg : node.inner_angle_deg, + tangent_m: null, + violations: curve ? (curve.violations ?? []) : (node.violations ?? []), + }); + out.curveOn.push(curve !== undefined); + // 서버가 고른 반지름을 **그대로 들고 간다** — 안 그러면 편집 한 번에 하한으로 눌린다. + out.curveRadius.push(curve ? Math.round(curve.radius_m * 100) / 100 : null); + out.curveLock.push(null); + out.curveArc.push(null); + if (curve) out.curves.push({ ...curve, node_first: seat, node_last: seat }); + }); + return out; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_History.ts b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts index a460c458..9db4946c 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_History.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts @@ -17,6 +17,10 @@ export interface RouteEditSnapshot { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array; + /** 무엇을 붙들고 있나 — 반지름 | 곡선 길이 | 없음 (`_Edits.ts`). */ + curveLock: Array<"radius" | "arc" | null>; + /** 길이를 붙들었을 때의 그 길이(m). */ + curveArc: Array; picked: number; } @@ -44,6 +48,8 @@ function clone(snapshot: RouteEditSnapshot): RouteEditSnapshot { planned: snapshot.planned.map(([x, y]): Vertex => [x, y]), curveOn: [...snapshot.curveOn], curveRadius: [...snapshot.curveRadius], + curveLock: [...snapshot.curveLock], + curveArc: [...snapshot.curveArc], picked: snapshot.picked, }; } @@ -56,6 +62,8 @@ function sameRoute(a: RouteEditSnapshot, b: RouteEditSnapshot): boolean { if (a.planned[index][1] !== b.planned[index][1]) return false; if (a.curveOn[index] !== b.curveOn[index]) return false; if (a.curveRadius[index] !== b.curveRadius[index]) return false; + if (a.curveLock[index] !== b.curveLock[index]) return false; + if (a.curveArc[index] !== b.curveArc[index]) return false; } return true; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts index 9096e81b..b1a37a79 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts @@ -1,25 +1,31 @@ /* ============================================================================= * B05_Profile_UI_RouteEdit_Label.ts - * 고른 꺾임점 **옆에 뜨는 곡선 라벨** — 반지름과 곡선 길이로 곡선을 만진다. + * 고른 꺾임점 옆에 뜨는 **곡선 조작 패널** — 반지름·곡선 길이를 보고 고치고 붙든다. * - * 왜 옮겼나(2026-09-07 사용자 지시 ③) — 예전에는 모달 **맨 아래 줄**이었다. 지금 고른 것이 - * 지도 어디인지 눈으로 이어지지 않아, 값을 바꾸면서도 어느 곡선을 만지는지 몰랐다. + * 왜 옆인가(2026-09-07 사용자 지시 ③) — 예전에는 모달 **맨 아래 줄**이라 지금 고른 것이 + * 지도 어디인지 눈으로 안 이어졌다. * - * **R 과 곡선 길이 두 칸이 한 쌍이다**(2026-09-07 사용자 지시) — 교각(Δ)은 앞뒤 직선이 - * 정하므로 둘은 L = R·Δ 로 묶여 있다. 한쪽을 고치면 다른 쪽이 따라온다. 「반지름 자동」 - * 단추는 없앴다 — 칸을 비우는 것이 곧 자동이다. + * **R 과 곡선 길이는 한 쌍**(L = R·Δ, Δ 는 앞뒤 직선이 정하는 교각) — 한쪽을 고치면 다른 + * 쪽이 따라온다. 「자동」 단추는 없다: 칸을 비우는 것이 곧 자동이다. 어느 쪽을 붙들지는 + * **고정 단추**로 정한다(둘 다 붙들 수는 없다 — `_Edits.ts` 설명 참고). * - * **자리는 곡선 중심의 반대쪽**(2026-09-07 사용자 지시) — 중심 쪽에 두면 라벨이 곡선을 - * 가린다. 상하좌우 네 방향 중 중심에서 먼 쪽에 붙이고, 캔버스 밖으로 나가면 안으로 접는다. + * **자리**(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. */ @@ -42,48 +48,57 @@ export function centerDirectionOf( return length <= 1e-9 ? null : [sx / length, sy / length]; } -/** 라벨과 노드 사이 여백(px). */ -const GAP_PX = 14; -/** 캔버스 가장자리에서 이만큼은 띄운다(px). */ -const EDGE_PX = 8; +/** 꺾임점과 패널 사이 여백(px) — 손잡이(접선점 네모)를 가리지 않을 만큼 띄운다. */ +const GAP_PX = 40; +/** 자동 자리를 고를 방위 수 — 16방위(22.5°마다). */ +const COMPASS_STEPS = 16; export interface CurveLabelState { /** 몇 번째 꺾임점인지 — 0부터 센 자리. 표시는 +1 해서 낸다. */ seat: number; - /** 그 꺾임점의 화면 좌표(캔버스 기준 px). */ + /** 그 꺾임점의 **화면(viewport) 좌표** px — 패널이 `position: fixed` 라서. */ at: [number, number]; - /** **곡선 중심이 있는 쪽**(화면 기준 방향벡터). 라벨은 이 반대쪽에 붙는다. - * 곡선이 없으면 null — 그때는 오른쪽에 둔다. */ + /** **곡선 중심이 있는 쪽**(화면 기준 방향벡터). 패널은 이 반대쪽에 붙는다. */ centerDirection: [number, number] | null; - /** 캔버스 크기(px) — 라벨을 안쪽으로 접어 넣는 데 쓴다. */ - canvas: { width: number; height: number }; curveOn: boolean; - /** 지금 보일 반지름(m). 곡선이 없으면 null. */ radiusShown: number | null; - /** 지금 보일 곡선 길이(m) = R·Δ. 곡선이 없으면 null. */ + /** 곡선 길이(m) = R·Δ. 곡선이 없으면 null. */ arcLengthShown: number | null; - /** 사용자가 못박은 값인지(아니면 자동). */ - forced: boolean; + lock: CurveLock; innerAngleDeg: number | null; - minRadiusM: number; } export interface CurveLabelHandlers { - /** R 칸을 고쳤다 — null 이면 「자동」(칸을 비운 것). */ onRadius: (value: number | null) => void; - /** 곡선 길이 칸을 고쳤다 — null 이면 「자동」. */ 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; } -/** 라벨을 만들어 `host`(캔버스를 감싼 칸, `position: relative`)에 붙인다. */ -export function createCurveLabel(host: HTMLElement, handlers: CurveLabelHandlers): CurveLabel { +/** 방향을 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; @@ -95,26 +110,40 @@ export function createCurveLabel(host: HTMLElement, handlers: CurveLabelHandlers - `; - host.append(root); + + 칸을 비우면 자동`; + 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")!; - // 라벨 위에서 누른 것이 캔버스로 새어 나가면 노드가 딸려 움직인다. - root.addEventListener("pointerdown", (event) => event.stopPropagation()); - root.addEventListener("dblclick", (event) => event.stopPropagation()); - root.addEventListener("contextmenu", (event) => event.stopPropagation()); + // 패널 위에서 누른 것이 캔버스로 새어 나가면 노드가 딸려 움직인다. + 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; @@ -122,53 +151,83 @@ export function createCurveLabel(host: HTMLElement, handlers: CurveLabelHandlers 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 direction = state.centerDirection; - let left = nx + GAP_PX; // 곡선이 없으면 오른쪽이 기본이다. - let top = ny - height / 2; - if (direction) { - const [dx, dy] = direction; - if (Math.abs(dx) >= Math.abs(dy)) { - // 중심이 오른쪽이면 라벨은 왼쪽으로. - left = dx > 0 ? nx - GAP_PX - width : nx + GAP_PX; - top = ny - height / 2; - } else { - left = nx - width / 2; - top = dy > 0 ? ny - GAP_PX - height : ny + GAP_PX; - } - } - // 캔버스 밖으로 나가면 안으로 접는다 — 노선 끝을 골라도 칸이 잘리지 않게. - left = Math.max(EDGE_PX, Math.min(left, state.canvas.width - width - EDGE_PX)); - top = Math.max(EDGE_PX, Math.min(top, state.canvas.height - height - EDGE_PX)); + 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 - ? `${state.forced ? "값 지정" : "자동"}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}` + - ` · 법정 하한 ${state.minRadiusM}m · 칸을 비우면 자동` + ? `${held}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}` : "곡선 없음 — 직선이 그대로 꺾입니다"; place(state); - // 글자가 바뀌면 상자 높이가 한 박자 늦게 자란다 — 다음 그림 직전에 한 번 더 맞춘다 - // (2026-09-07 실측: 처음 잰 높이 120px, 실제 131px 라 11px 어긋났음). + // 글자가 바뀌면 상자 높이가 한 박자 늦게 자란다 — 다음 그림 직전에 한 번 더 맞춘다. requestAnimationFrame(() => { if (!root.hidden) place(state); }); @@ -176,5 +235,8 @@ export function createCurveLabel(host: HTMLElement, handlers: CurveLabelHandlers hide() { root.hidden = true; }, + destroy() { + root.remove(); + }, }; } diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index 6b4791db..789350c7 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -135,8 +135,10 @@ 예전에는 모달 맨 아랫줄이라 지금 고른 것이 지도 어디인지 눈으로 안 이어졌다. 자리는 `_Label.ts` 가 매 프레임 잡아 준다 — 여기서는 모양만 정한다. */ .b05-routeedit__label { - position: absolute; - z-index: 2; + /* `document.body` 에 붙여 **화면 기준**으로 띄운다 — 모달이 `overflow: hidden` 이라 + 안에 두면 가장자리에서 잘린다(2026-09-07 사용자 지시 1). */ + position: fixed; + z-index: calc(var(--z-modal, 1000) + 1); display: flex; flex-direction: column; gap: 4px; @@ -152,11 +154,41 @@ font-size: var(--text-caption); } +/* 머리는 **잡아 옮기는 자리**다 — 패널이 곡선을 가리면 손으로 치울 수 있어야 한다. */ .b05-routeedit__label-head { display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-8, 8px); + cursor: move; + touch-action: none; +} + +/* 고정 단추 — 켜지면 색이 찬다. 켠 값은 노드를 옮겨도 안 바뀐다. */ +.b05-routeedit__lock { + padding: 1px 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-4, 4px); + background: var(--color-surface); + color: var(--color-text-secondary); + font: inherit; + cursor: pointer; +} + +.b05-routeedit__lock.is-on { + border-color: transparent; + background: var(--color-primary, #7c3aed); + color: #fff; +} + +.b05-routeedit__lock:disabled { + color: var(--color-text-secondary); + cursor: default; +} + +/* 「칸을 비우면 자동」 — 상태 설명과 줄을 나눈다(2026-09-07 사용자 지시 4). */ +.b05-routeedit__curve-note { + color: var(--color-text-secondary); } .b05-routeedit__label-toggle {