feat(B05): 곡선 시작·끝점을 끌면 직선 각도와 R 이 함께 바뀜
사용자 확정(2026-09-07) — 「직선과 곡선 교차점 이동 시에는 직선의 각도와 반지름 값 변경이 되」. 되물어 확정: 끄는 점은 **곡선 시작·끝점**. 셈은 서버의 반대 방향임 — 서버는 교각점·R 에서 접선점을 내고, 화면은 끈 접선점에서 교각점·R 을 구함. 끈 쪽 직선만 돌리고 반대쪽은 옛 교각점을 지나는 그대로 두어, 두 직선이 만나는 자리가 새 교각점이 되고 R = |새 교각점 − 끈 자리| / tan(교각/2) 로 나옴. **두 벌이 아니라 짝임** — 왕복이 제자리인지를 tmp/tests/test_route_polyline_handle_drag.py 3건이 지킴. 화면이 낸 (교각점, R) 을 서버 셈에 넣으면 끈 자리가 **1µm 안**으로 되돌아옴. 접선점을 직선 위에서만 밀면 교각점이 안 움직이고 R 만 커지는 것도 확인. 손잡이를 **속 빈 네모(5px)** 로 키움 — 3.5px 채운 네모는 선·노드와 색이 같아 눈에도 안 띄고 집기도 어려웠음(실화면). 곡선을 지운 자리에는 손잡이도 없음. 실화면 확인(용화) — 손잡이를 끌자 「14번째 꺾임점 · R 16.6 · 값 지정」으로 바뀜. 서버가 곡선 성분 22개(예: R 44.3m, 노드 1~3을 한 곡선으로)를 내려줌. ⚠ 그 확인 도중 **서버가 또 옛 코드로 돌고 있었음**(응답에 `curves` 키 자체가 없었음). 오늘 두 번째임 — 화면 검증 전에 응답에 새 필드가 있는지부터 볼 것. tsc --noEmit 통과 · pytest 430 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -37,8 +37,9 @@ const NODE_HIT_PX = 9;
|
||||
const NODE_R = 4;
|
||||
/** 끌기로 볼 최소 이동(px) — 이보다 작으면 클릭으로 본다. */
|
||||
const DRAG_THRESHOLD_PX = 3;
|
||||
/** 곡선 시작·끝점 손잡이 크기(px) — 노드 동그라미와 구별되게 네모로 그린다. */
|
||||
const CURVE_HANDLE_PX = 3.5;
|
||||
/** 곡선 시작·끝점 손잡이 크기(px) — 노드 동그라미와 구별되게 **속 빈 네모**로 그린다.
|
||||
* 처음엔 3.5px 였는데 선과 색이 같아 눈에도 안 띄고 집기도 어려웠다(2026-09-07 실화면). */
|
||||
const CURVE_HANDLE_PX = 5;
|
||||
|
||||
type Vertex = [number, number];
|
||||
|
||||
@@ -233,9 +234,12 @@ export async function openRouteEditModal(
|
||||
}
|
||||
});
|
||||
|
||||
// 곡선 시작·끝점 — 사용자가 눈으로 곡선 범위를 알아보는 자리(2026-09-07 사용자 지시).
|
||||
context.fillStyle = style.getPropertyValue("--map-route") || "#f97316";
|
||||
// 곡선 시작·끝점 — 잡아서 직선 각도와 R 을 함께 바꾸는 손잡이(2026-09-07 사용자 지시).
|
||||
// **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다.
|
||||
context.lineWidth = 2;
|
||||
curveInfo.forEach((curve) => {
|
||||
const on = curveOn[curve.node_first] !== false;
|
||||
if (!on) return; // 곡선을 지운 자리에는 손잡이도 없다.
|
||||
[curve.start, curve.end].forEach((point) => {
|
||||
const [x, y] = toScreen([point[0], point[1]]);
|
||||
context.beginPath();
|
||||
@@ -245,13 +249,97 @@ export async function openRouteEditModal(
|
||||
CURVE_HANDLE_PX * 2,
|
||||
CURVE_HANDLE_PX * 2,
|
||||
);
|
||||
context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)";
|
||||
context.fill();
|
||||
context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316";
|
||||
context.stroke();
|
||||
});
|
||||
});
|
||||
context.restore();
|
||||
}
|
||||
|
||||
/** 화면 좌표에 가장 가까운 **곡선 손잡이**(시작·끝점). 없으면 null. */
|
||||
function handleAt(px: number, py: number): { curve: number; end: "start" | "end" } | null {
|
||||
let best: { curve: number; end: "start" | "end" } | null = null;
|
||||
let bestDistance = NODE_HIT_PX + 2;
|
||||
curveInfo.forEach((curve, index) => {
|
||||
(["start", "end"] as const).forEach((which) => {
|
||||
const point = which === "start" ? curve.start : curve.end;
|
||||
const [x, y] = toScreen([point[0], point[1]]);
|
||||
const distance = Math.hypot(x - px, y - py);
|
||||
if (distance <= bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = { curve: index, end: which };
|
||||
}
|
||||
});
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 끈 접선점으로 **새 교각점과 새 반지름**을 구한다 — 「직선의 각도와 반지름 값 변경」.
|
||||
*
|
||||
* 사용자 확정(2026-09-07) — 곡선 시작·끝점을 옮기면 그쪽 **직선 각도**와 **반지름**이 함께
|
||||
* 바뀐다(반대로 반지름만 바꿀 때는 직선이 고정이다).
|
||||
*
|
||||
* 셈은 이렇다. 끈 것이 시작점이면 들어오는 직선은 **앞 노드 → 끈 자리**로 돌아간다.
|
||||
* 나가는 직선은 그대로이므로 **두 직선이 만나는 자리**가 새 교각점이고, 접선 길이
|
||||
* T = |새 교각점 − 끈 자리| 에서 R = T / tan(교각/2) 가 나온다.
|
||||
*
|
||||
* ⚠ 이 셈은 **서버의 반대 방향**이다(서버는 교각점·R 에서 접선점을 낸다). 두 벌이 아니라
|
||||
* 짝이며, 왕복이 제자리인지는 `tmp/tests/test_route_polyline_handle_drag.py` 가 지킨다.
|
||||
*/
|
||||
function dragHandleTo(
|
||||
curveIndex: number,
|
||||
which: "start" | "end",
|
||||
to: Vertex,
|
||||
): { apex: Vertex; radius: number } | null {
|
||||
const curve = curveInfo[curveIndex];
|
||||
if (!curve) return null;
|
||||
const node = curve.node_first;
|
||||
const before = planned[node - 1];
|
||||
const after = planned[node + 1];
|
||||
if (!before || !after) return null;
|
||||
const oldApex: Vertex = [curve.apex[0], curve.apex[1]];
|
||||
// 끈 쪽 직선만 돌아간다 — 반대쪽 직선은 옛 교각점을 지나는 그대로다.
|
||||
const apex =
|
||||
which === "start"
|
||||
? intersect(before, to, oldApex, after) // 들어오는 직선이 끈 자리를 지나게 돌린다
|
||||
: intersect(before, oldApex, to, after); // 나가는 직선을 돌린다
|
||||
if (!apex) return null;
|
||||
const inner = innerAngleDeg(before, apex, after);
|
||||
const halfTan = Math.tan(((180 - inner) * Math.PI) / 360);
|
||||
if (!(halfTan > 1e-9)) return null;
|
||||
const tangent = Math.hypot(apex[0] - to[0], apex[1] - to[1]);
|
||||
const radius = tangent / halfTan;
|
||||
if (!(radius > 0) || !Number.isFinite(radius)) return null;
|
||||
return { apex, radius };
|
||||
}
|
||||
|
||||
/** 두 **직선**(선분 아님)이 만나는 자리. 나란하면 null. */
|
||||
function intersect(a1: Vertex, a2: Vertex, b1: Vertex, b2: Vertex): Vertex | null {
|
||||
const dx1 = a2[0] - a1[0];
|
||||
const dy1 = a2[1] - a1[1];
|
||||
const dx2 = b2[0] - b1[0];
|
||||
const dy2 = b2[1] - b1[1];
|
||||
const denominator = dx1 * dy2 - dy1 * dx2;
|
||||
if (Math.abs(denominator) <= 1e-12) return null;
|
||||
const t = ((b1[0] - a1[0]) * dy2 - (b1[1] - a1[1]) * dx2) / denominator;
|
||||
return [a1[0] + dx1 * t, a1[1] + dy1 * t];
|
||||
}
|
||||
|
||||
/** 세 점이 이루는 내각(도). 일직선이면 180. */
|
||||
function innerAngleDeg(before: Vertex, at: Vertex, after: Vertex): number {
|
||||
const ax = before[0] - at[0];
|
||||
const ay = before[1] - at[1];
|
||||
const bx = after[0] - at[0];
|
||||
const by = after[1] - at[1];
|
||||
const la = Math.hypot(ax, ay);
|
||||
const lb = Math.hypot(bx, by);
|
||||
if (la <= 0 || lb <= 0) return 180;
|
||||
const cosine = Math.max(-1, Math.min(1, (ax * bx + ay * by) / (la * lb)));
|
||||
return (Math.acos(cosine) * 180) / Math.PI;
|
||||
}
|
||||
|
||||
/** 화면 좌표에 가장 가까운 노드. 문턱 밖이면 -1. */
|
||||
function nodeAt(px: number, py: number): number {
|
||||
let best = -1;
|
||||
@@ -388,6 +476,8 @@ export async function openRouteEditModal(
|
||||
|
||||
// ── 조작 — 노드 끌기 / 배경 끌기(팬) / 휠 확대 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ──
|
||||
let dragNode = -1;
|
||||
/** 끌고 있는 곡선 손잡이(시작·끝점). 노드 끌기보다 우선한다. */
|
||||
let dragHandle: { curve: number; end: "start" | "end" } | null = null;
|
||||
let panFrom: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
|
||||
|
||||
canvas.addEventListener("pointerdown", (event) => {
|
||||
@@ -395,8 +485,14 @@ export async function openRouteEditModal(
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const px = event.clientX - rect.left;
|
||||
const py = event.clientY - rect.top;
|
||||
dragNode = nodeAt(px, py);
|
||||
if (dragNode >= 0) {
|
||||
// 곡선 손잡이가 노드보다 먼저다 — 겹치면 손잡이를 잡는다(더 세밀한 조작).
|
||||
dragHandle = handleAt(px, py);
|
||||
dragNode = dragHandle ? -1 : nodeAt(px, py);
|
||||
if (dragHandle) {
|
||||
picked = curveInfo[dragHandle.curve]?.node_first ?? -1;
|
||||
syncCurveBar();
|
||||
draw();
|
||||
} else if (dragNode >= 0) {
|
||||
picked = dragNode; // 누른 자리를 고른다 — 편집줄이 그 곡선을 만진다.
|
||||
syncCurveBar();
|
||||
draw();
|
||||
@@ -410,6 +506,30 @@ export async function openRouteEditModal(
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const px = event.clientX - rect.left;
|
||||
const py = event.clientY - rect.top;
|
||||
if (dragHandle) {
|
||||
// 곡선 시작·끝점을 끈다 — 그쪽 직선 각도와 반지름이 함께 바뀐다(2026-09-07 사용자 확정).
|
||||
const moved = dragHandleTo(dragHandle.curve, dragHandle.end, toMetric(px, py));
|
||||
if (moved) {
|
||||
const node = curveInfo[dragHandle.curve].node_first;
|
||||
planned[node] = moved.apex;
|
||||
curveRadius[node] = Math.round(moved.radius * 100) / 100;
|
||||
curveOn[node] = true;
|
||||
picked = node;
|
||||
// 손잡이 자리도 따라 움직여야 계속 끌 수 있다 — 그림은 [확인] 때 서버가 다시 낸다.
|
||||
const which = dragHandle.end === "start" ? "start" : "end";
|
||||
curveInfo[dragHandle.curve] = {
|
||||
...curveInfo[dragHandle.curve],
|
||||
apex: [moved.apex[0], moved.apex[1]],
|
||||
radius_m: moved.radius,
|
||||
[which]: toMetric(px, py),
|
||||
} as (typeof curveInfo)[number];
|
||||
plannedLine = [];
|
||||
nodeInfo = [];
|
||||
syncCurveBar();
|
||||
draw();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (dragNode >= 0) {
|
||||
planned[dragNode] = toMetric(px, py);
|
||||
markEdited(); // 폴리라인은 [확인] 때 서버가 다시 만든다 — 지금은 직선으로 미리 보인다.
|
||||
@@ -426,12 +546,13 @@ export async function openRouteEditModal(
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
canvas.style.cursor = nodeAt(px, py) >= 0 ? "grab" : "default";
|
||||
canvas.style.cursor = handleAt(px, py) || nodeAt(px, py) >= 0 ? "grab" : "default";
|
||||
});
|
||||
|
||||
const endDrag = (event: PointerEvent): void => {
|
||||
if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
|
||||
dragNode = -1;
|
||||
dragHandle = null;
|
||||
panFrom = null;
|
||||
};
|
||||
canvas.addEventListener("pointerup", endDrag);
|
||||
|
||||
Reference in New Issue
Block a user