feat(B05): 노선 편집 모달이 정점 대신 노드를 잡게 함

사용자 지시(2026-09-06) — 노드를 제어해 계획노선을 고친다. 그동안 모달은 서버가 준
폴리라인 정점을 그대로 잡았는데, 거기에는 원호 위 점이 섞여 있어 편집 대상이 아님.

- 그려 보이는 선(폴리라인, 원호 포함)과 잡는 점(노드)을 나눔. 선은 plannedLine,
  노드는 서버가 내려 준 nodes.
- 노드에 붙은 반지름·내각·법정 위반을 화면에 실음 — 위반 노드는 붉게, 상태줄에
  곡선 수와 기준 R, 미달 개수.
- 노드를 옮기면 폴리라인은 낡은 값이므로 지우고 직선으로 미리 보임. 곡선은 [확인] 때
  서버가 같은 R 규칙으로 다시 끼움(계산을 두 벌로 짜지 않음).
- API 타입에 nodes·min_radius_m·curve_count·violation_count 추가.

자체검증(공용 브라우저, 용화) — 모달이 노드 28개로 열리고 상태줄에
「노드 28개 · 초기 폴리라인 · 곡선 13곳(R 12m)」. 전에는 폴리라인 정점 142개를 잡았음.
typecheck 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 00:09:49 +09:00
co-authored by Claude Opus 5
parent 00decc812d
commit 9b93d3055b
2 changed files with 81 additions and 9 deletions
+21 -2
View File
@@ -15,13 +15,32 @@ import { API_BASE_URL } from "@config/config_frontend";
/** 노선 재계산 대기 상한 — 배수유역 분석(90초대)까지 포함해 넉넉히 잡는다. */
const REPLAN_TIMEOUT_MS = 15 * 60 * 1000;
/** 사용자가 잡아 옮기는 제어점 하나 — 서버가 이 노드로 폴리라인을 만든다. */
export interface RoutePlanNode {
x: number;
y: number;
/** 직전·직후 구간이 이루는 내각(도). 끝점은 null. */
inner_angle_deg: number | null;
/** 이 자리에 끼운 원호 반지름(m). 곡선을 안 둔 자리(내각 155° 이상)는 null. */
radius_m: number | null;
tangent_m: number | null;
/** 법정 기준 위반 표시 — 값은 내되 막지 않는다. */
violations: string[];
}
export interface RoutePlanResponse {
status: string;
project_id: string;
/** 예상노선(원본) 정점 [[x, y], …] — 사업지 좌표계(m). */
/** 예상노선(원본) **점 묶음** [[x, y], …] — 사업지 좌표계(m). 폴리라인이 아니다. */
expected: Array<[number, number]>;
/** 계획노선(수정본). 고친 적이 없으면 예상노선과 같은 값. */
/** 계획노선 폴리라인(원호 포함) — 그려 보이는 선. 잡는 대상이 아니다. */
planned: Array<[number, number]>;
/** 잡아 옮기는 노드(꺾임점). 편집은 이것으로 한다(2026-09-06 사용자 지시). */
nodes: RoutePlanNode[];
/** 이 프로젝트에 적용한 법정 최소곡선반지름(m). */
min_radius_m: number;
curve_count: number;
violation_count: number;
/** 사용자가 고친 계획노선이 저장돼 있으면 true. */
edited: boolean;
}
+60 -7
View File
@@ -76,7 +76,17 @@ export async function openRouteEditModal(
const context = canvas.getContext("2d")!;
let expected: Vertex[] = [];
/** 그려 보이는 계획노선 — 원호가 섞인 폴리라인. **잡는 대상이 아니다.** */
let plannedLine: Vertex[] = [];
/** 사용자가 잡아 옮기는 **노드**(꺾임점). 서버가 이 노드로 폴리라인을 다시 만든다. */
let planned: Vertex[] = [];
/** 노드마다의 반지름·내각·법정 위반 — 서버가 함께 내려 준다(표시용). */
let nodeInfo: Array<{
radius_m: number | null;
inner_angle_deg: number | null;
violations: string[];
}> = [];
let minRadiusM = 0;
let meta: VWorldMeta | null = null;
let sheets: PreparedLayer[] = [];
let view: ViewState = {
@@ -167,19 +177,31 @@ export async function openRouteEditModal(
style.getPropertyValue("--color-text-secondary") || "#9ca3af",
1.6,
);
strokePolyline(planned, [], style.getPropertyValue("--map-route") || "#f97316", 2.4);
// 선은 **폴리라인**(원호 포함)을 그리고, 잡는 동그라미는 **노드**에만 찍는다.
// 노드를 옮기는 동안에는 폴리라인이 없으므로 노드를 곧바로 이어 미리 보인다.
strokePolyline(
plannedLine.length ? plannedLine : planned,
[],
style.getPropertyValue("--map-route") || "#f97316",
2.4,
);
context.save();
context.fillStyle = style.getPropertyValue("--map-route") || "#f97316";
context.strokeStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
context.lineWidth = 1;
for (const vertex of planned) {
planned.forEach((vertex, index) => {
const [x, y] = toScreen(vertex);
// 법정 기준을 못 맞춘 자리는 붉게 — 막지는 않고 보이기만 한다(2026-09-06 사용자 확정).
const bad = (nodeInfo[index]?.violations?.length ?? 0) > 0;
context.fillStyle = bad
? style.getPropertyValue("--color-danger") || "#dc2626"
: style.getPropertyValue("--map-route") || "#f97316";
context.beginPath();
context.arc(x, y, NODE_R, 0, Math.PI * 2);
context.fill();
context.stroke();
}
});
context.restore();
}
@@ -218,6 +240,21 @@ export async function openRouteEditModal(
return best;
}
/** 노드를 고쳤다 — 서버가 만든 폴리라인은 낡았으므로 지우고 직선으로 미리 보인다.
* 곡선은 [확인] 때 서버가 같은 R 규칙으로 다시 끼운다(계산을 두 벌로 짜지 않는다). */
function markEdited(): void {
plannedLine = [];
nodeInfo = [];
}
/** 상태줄 꼬리 — 곡선 기준과 위반 수를 알린다. */
function curveHint(): string {
if (!nodeInfo.length) return minRadiusM ? `곡선 기준 R ${minRadiusM}m — [확인] 때 반영` : "";
const bad = nodeInfo.filter((node) => node.violations.length).length;
const curves = nodeInfo.filter((node) => node.radius_m !== null).length;
return `곡선 ${curves}곳(R ${minRadiusM}m)${bad ? ` · 기준 미달 ${bad}` : ""}`;
}
// ── 조작 — 노드 끌기 / 배경 끌기(팬) / 휠 확대 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ──
let dragNode = -1;
let panFrom: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
@@ -238,6 +275,7 @@ export async function openRouteEditModal(
const py = event.clientY - rect.top;
if (dragNode >= 0) {
planned[dragNode] = toMetric(px, py);
markEdited(); // 폴리라인은 [확인] 때 서버가 다시 만든다 — 지금은 직선으로 미리 보인다.
draw();
return;
}
@@ -269,7 +307,8 @@ export async function openRouteEditModal(
const segment = segmentAt(px, py);
if (segment < 0) return;
planned.splice(segment + 1, 0, toMetric(px, py));
status.textContent = `노드 ${planned.length}개 — 새 노드를 넣었습니다.`;
markEdited();
status.textContent = `노드 ${planned.length}개 — 새 노드를 넣었습니다. ${curveHint()}`;
draw();
});
@@ -283,7 +322,8 @@ export async function openRouteEditModal(
return;
}
planned.splice(index, 1);
status.textContent = `노드 ${planned.length}개 — 노드를 지웠습니다.`;
markEdited();
status.textContent = `노드 ${planned.length}개 — 노드를 지웠습니다. ${curveHint()}`;
draw();
});
@@ -346,7 +386,18 @@ export async function openRouteEditModal(
]);
if (closed) return;
expected = plan.expected as Vertex[];
planned = (plan.planned as Vertex[]).map((vertex) => [vertex[0], vertex[1]]);
plannedLine = (plan.planned as Vertex[]).map((vertex) => [vertex[0], vertex[1]]);
// 잡는 것은 **노드**다 — 폴리라인 정점에는 원호 위 점이 섞여 있어 편집 대상이 아니다
// (2026-09-06 사용자 지시: 노드를 제어해 계획노선을 고친다).
const nodes = plan.nodes ?? [];
planned = nodes.map((node) => [node.x, node.y] as Vertex);
nodeInfo = nodes.map((node) => ({
radius_m: node.radius_m,
inner_angle_deg: node.inner_angle_deg,
violations: node.violations ?? [],
}));
minRadiusM = plan.min_radius_m ?? 0;
if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
meta = drainage.meta;
const normalizer = createNormalizer(drainage.meta);
sheets = drainage.layers
@@ -367,7 +418,9 @@ export async function openRouteEditModal(
view.height,
);
view = { ...view, ...fitted };
status.textContent = `노드 ${planned.length}개 · ${plan.edited ? "고친 계획노선" : "예상노선과 같음"}`;
status.textContent =
`노드 ${planned.length}개 · ${plan.edited ? "고친 계획노선" : "초기 폴리라인"} · ` +
curveHint();
draw();
} catch (error) {
status.textContent = error instanceof Error ? error.message : "노선을 읽지 못했습니다.";