diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index c54db379..09a1f611 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -32,11 +32,16 @@ import type { RoutePlanCurve } from "./B05_Profile_Api_Replan"; import { buildEditedPolyline, dragHandleTo as curveDragTo } from "./B05_Profile_UI_RouteEdit_Curve"; import { bindRouteEditNavigation, + contourBandRect, handleAtScreen, nodeAtScreen, segmentAtScreen, } from "./B05_Profile_UI_RouteEdit_Input"; -import { createCurveLabel } from "./B05_Profile_UI_RouteEdit_Label"; +import { + centerDirectionOf, + createCurveLabel, + deflectionRad, +} from "./B05_Profile_UI_RouteEdit_Label"; import { bindHistoryControls, createRouteEditHistory, @@ -199,34 +204,6 @@ export async function openRouteEditModal( context.restore(); } - /** 등고선을 보일 화면 사각형 — 노선 경계에 `CONTOUR_BAND_M` 를 두른 것. - * - * **매 프레임 다시 잰다** — 창 크기·배율·이동이 바뀌어도 띠가 노선을 따라간다. 노선이 - * 아직 없으면 null(전체를 그린다). */ - function contourBandRect(): { x: number; y: number; width: number; height: number } | null { - const line = plannedLine.length ? plannedLine : planned; - if (!meta || line.length < 2) return null; - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - for (const [x, y] of line) { - if (x < minX) minX = x; - if (x > maxX) maxX = x; - if (y < minY) minY = y; - if (y > maxY) maxY = y; - } - // 사업지 좌표(m)에서 띠를 두르고 화면으로 옮긴다 — y 는 화면에서 뒤집힌다. - const [left, bottom] = toScreen([minX - CONTOUR_BAND_M, minY - CONTOUR_BAND_M]); - const [right, top] = toScreen([maxX + CONTOUR_BAND_M, maxY + CONTOUR_BAND_M]); - return { - x: Math.min(left, right), - y: Math.min(top, bottom), - width: Math.abs(right - left), - height: Math.abs(bottom - top), - }; - } - function draw(): void { if (closed) return; const style = getComputedStyle(document.documentElement); @@ -237,7 +214,9 @@ export async function openRouteEditModal( context.save(); // 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면 // 화면이 등고선으로 덮여 노선이 안 보인다(2026-09-07 사용자 지시 ⑥). - const band = contourBandRect(); + const band = meta + ? contourBandRect(plannedLine.length ? plannedLine : planned, toScreen, CONTOUR_BAND_M) + : null; if (band) { context.beginPath(); context.rect(band.x, band.y, band.width, band.height); @@ -293,21 +272,21 @@ export async function openRouteEditModal( // **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다. context.lineWidth = 2; curveInfo.forEach((curve) => { - // **고른 곡선에만** 손잡이를 낸다(2026-09-07 사용자 지적 ④) — 전부 내놓으면 헤어핀에서 - // 노드 위를 덮어 노드를 못 집는다. 먼저 노드를 눌러 고르고, 그 다음 손잡이를 끈다. - if (curve.node_first !== picked) return; + // **늘 보인다**(2026-09-07 사용자 지시) — 직선이 곡선에 닿는 자리는 손잡이이기 이전에 + // **읽을 정보**다. 한때 고른 곡선만 내보였더니 「표기가 다 사라졌다」는 지적을 받았다. + // 노드를 못 집던 문제는 집기 우선순위(노드가 먼저)로 따로 풀었으므로 다 내놓아도 된다. const on = curveOn[curve.node_first] !== false; - if (!on) return; // 곡선을 지운 자리에는 손잡이도 없다. + if (!on) return; // 곡선을 지운 자리에는 접선점도 없다. + // 고른 곡선은 속을 채워 도드라지게 — 지금 끌 수 있는 것이 무엇인지 보이게. + const isPicked = curve.node_first === picked; [curve.start, curve.end].forEach((point) => { const [x, y] = toScreen([point[0], point[1]]); context.beginPath(); - context.rect( - x - CURVE_HANDLE_PX, - y - CURVE_HANDLE_PX, - CURVE_HANDLE_PX * 2, - CURVE_HANDLE_PX * 2, - ); - context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)"; + const size = isPicked ? CURVE_HANDLE_PX + 1 : CURVE_HANDLE_PX; + context.rect(x - size, y - size, size * 2, size * 2); + context.fillStyle = isPicked + ? style.getPropertyValue("--map-route") || "#f97316" + : style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)"; context.fill(); context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316"; context.stroke(); @@ -320,7 +299,7 @@ export async function openRouteEditModal( /** 잡기 — 셈은 `_RouteEdit_Input` 몫이고, 여기서는 지금 상태를 건네준다. */ const handleAt = (px: number, py: number): { node: number; end: "start" | "end" } | null => - handleAtScreen(curveInfo, picked, toScreen, px, py, NODE_HIT_PX + 2); + handleAtScreen(curveInfo, 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 => @@ -374,7 +353,7 @@ export async function openRouteEditModal( ); } - // ── R 라벨 — 고른 꺾임점 **옆에** 뜬다(2026-09-07 사용자 지시 ③). 그리기는 `_Label` 몫 ── + // ── 곡선 라벨 — 고른 꺾임점 옆(곡선 중심 반대쪽)에 뜬다. 그리기는 `_Label` 몫 ── const curveLabelBox = createCurveLabel(canvas.parentElement!, { onRadius: (value) => { if (picked < 0) return; @@ -382,16 +361,19 @@ export async function openRouteEditModal( // 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다. applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다."); }, + onArcLength: (value) => { + if (picked < 0) return; + // 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함). + // 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다. + const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg); + curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null; + applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다."); + }, onCurveOn: (on) => { if (picked < 0) return; curveOn[picked] = on; applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다."); }, - onAuto: () => { - if (picked < 0) return; - curveRadius[picked] = null; // 서버가 예정노선에 맞춰 다시 고른다. - applyEdit("반지름을 자동으로 되돌렸습니다."); - }, }); /** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */ @@ -401,13 +383,23 @@ export async function openRouteEditModal( return; } const forced = curveRadius[picked]; + const pickedCurve = curveInfo.find((entry) => entry.node_first === picked); + const shown = forced ?? pickedCurve?.radius_m ?? null; + const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg); curveLabelBox.show({ seat: picked, at: toScreen(planned[picked]), + centerDirection: pickedCurve + ? centerDirectionOf( + toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]), + toScreen(pickedCurve.start), + toScreen(pickedCurve.end), + ) + : null, canvas: { width: view.width, height: view.height }, curveOn: curveOn[picked] !== false, - radiusShown: - forced ?? curveInfo.find((entry) => entry.node_first === picked)?.radius_m ?? null, + radiusShown: shown, + arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection, forced: forced !== null, innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null, minRadiusM, diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts index a73dd52e..89555c43 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts @@ -126,14 +126,14 @@ export function nodeAtScreen( return best; } -/** 화면 좌표에 가장 가까운 **곡선 손잡이**(시작·끝점). 없으면 null. +/** 화면 좌표에 가장 가까운 **곡선 손잡이**(접선점). 없으면 null. * - * **고른 곡선만** 본다 — 그려 보이는 것만 잡혀야 한다. 잡은 것은 곡선 목록 자리가 아니라 - * **노드 번호**로 돌려준다: 목록은 고칠 때마다 새로 나므로 자리로 들면 끄는 도중 엉뚱한 - * 곡선을 가리킨다. */ + * 곡선 **전부**를 본다 — 접선점은 늘 그려지므로 늘 잡혀야 한다. 노드를 못 집던 문제는 + * 부르는 쪽에서 **노드를 먼저** 보는 것으로 풀었다(2026-09-07). + * 잡은 것은 곡선 목록 자리가 아니라 **노드 번호**로 돌려준다: 목록은 고칠 때마다 새로 나므로 + * 자리로 들면 끄는 도중 엉뚱한 곡선을 가리킨다. */ export function handleAtScreen( curves: Array<{ node_first: number; start: [number, number]; end: [number, number] }>, - picked: number, toScreen: ScreenOf, px: number, py: number, @@ -142,7 +142,6 @@ export function handleAtScreen( let best: { node: number; end: "start" | "end" } | null = null; let bestDistance = hitPx; curves.forEach((curve) => { - if (curve.node_first !== picked) return; (["start", "end"] as const).forEach((which) => { const point = which === "start" ? curve.start : curve.end; const [x, y] = toScreen([point[0], point[1]]); @@ -181,3 +180,34 @@ export function segmentAtScreen( } return best; } + +/** 등고선을 보일 화면 사각형 — 노선 경계에 `bandM` 를 두른 것. 노선이 없으면 null. + * + * **매 프레임 다시 잰다** — 창 크기·배율·이동이 바뀌어도 띠가 노선을 따라간다. 띠 자체는 + * 미터로 정해 두므로 **창 크기와 무관**하다(2026-09-07 사용자 지시 ⑥). */ +export function contourBandRect( + line: Array<[number, number]>, + toScreen: ScreenOf, + bandM: number, +): { x: number; y: number; width: number; height: number } | null { + if (line.length < 2) return null; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const [x, y] of line) { + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + // 사업지 좌표(m)에서 띠를 두르고 화면으로 옮긴다 — y 는 화면에서 뒤집힌다. + const [left, bottom] = toScreen([minX - bandM, minY - bandM]); + const [right, top] = toScreen([maxX + bandM, maxY + bandM]); + return { + x: Math.min(left, right), + y: Math.min(top, bottom), + width: Math.abs(right - left), + height: Math.abs(bottom - top), + }; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts index f6538512..9096e81b 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts @@ -1,15 +1,47 @@ /* ============================================================================= * B05_Profile_UI_RouteEdit_Label.ts - * 고른 꺾임점 **옆에 뜨는 R 라벨** — 반지름을 바꾸고, 곡선을 지우고 넣는다. + * 고른 꺾임점 **옆에 뜨는 곡선 라벨** — 반지름과 곡선 길이로 곡선을 만진다. * * 왜 옮겼나(2026-09-07 사용자 지시 ③) — 예전에는 모달 **맨 아래 줄**이었다. 지금 고른 것이 * 지도 어디인지 눈으로 이어지지 않아, 값을 바꾸면서도 어느 곡선을 만지는지 몰랐다. - * 「해당 요소를 선택하면 우측에 라벨 같은 입력이 보이게」가 사용자의 말 그대로다. * - * 캔버스 밖으로 나가지 않게 가장자리에서 **반대쪽으로 접어 넣는다** — 노선 끝의 꺾임점을 - * 골라도 입력칸이 잘리지 않아야 한다. + * **R 과 곡선 길이 두 칸이 한 쌍이다**(2026-09-07 사용자 지시) — 교각(Δ)은 앞뒤 직선이 + * 정하므로 둘은 L = R·Δ 로 묶여 있다. 한쪽을 고치면 다른 쪽이 따라온다. 「반지름 자동」 + * 단추는 없앴다 — 칸을 비우는 것이 곧 자동이다. + * + * **자리는 곡선 중심의 반대쪽**(2026-09-07 사용자 지시) — 중심 쪽에 두면 라벨이 곡선을 + * 가린다. 상하좌우 네 방향 중 중심에서 먼 쪽에 붙이고, 캔버스 밖으로 나가면 안으로 접는다. * ========================================================================== */ +/** 그 꺾임점의 **교각 Δ**(라디안) — 내각의 나머지. 곡선 길이 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 = 14; /** 캔버스 가장자리에서 이만큼은 띄운다(px). */ @@ -20,11 +52,16 @@ export interface CurveLabelState { seat: number; /** 그 꺾임점의 화면 좌표(캔버스 기준 px). */ at: [number, number]; + /** **곡선 중심이 있는 쪽**(화면 기준 방향벡터). 라벨은 이 반대쪽에 붙는다. + * 곡선이 없으면 null — 그때는 오른쪽에 둔다. */ + centerDirection: [number, number] | null; /** 캔버스 크기(px) — 라벨을 안쪽으로 접어 넣는 데 쓴다. */ canvas: { width: number; height: number }; curveOn: boolean; /** 지금 보일 반지름(m). 곡선이 없으면 null. */ radiusShown: number | null; + /** 지금 보일 곡선 길이(m) = R·Δ. 곡선이 없으면 null. */ + arcLengthShown: number | null; /** 사용자가 못박은 값인지(아니면 자동). */ forced: boolean; innerAngleDeg: number | null; @@ -32,12 +69,12 @@ export interface CurveLabelState { } export interface CurveLabelHandlers { - /** R 칸을 고쳤다 — null 이면 「자동」. */ + /** R 칸을 고쳤다 — null 이면 「자동」(칸을 비운 것). */ onRadius: (value: number | null) => void; + /** 곡선 길이 칸을 고쳤다 — null 이면 「자동」. */ + onArcLength: (value: number | null) => void; /** 곡선을 지우거나 넣었다. */ onCurveOn: (on: boolean) => void; - /** 반지름을 자동으로 되돌렸다. */ - onAuto: () => void; } export interface CurveLabel { @@ -55,10 +92,13 @@ export function createCurveLabel(host: HTMLElement, handlers: CurveLabelHandlers -