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
-